diff --git a/fw/config/config.exs b/fw/config/config.exs index 05b6b3e..0cbb8fd 100644 --- a/fw/config/config.exs +++ b/fw/config/config.exs @@ -6,3 +6,7 @@ use Mix.Config import_config "#{Mix.Project.config()[:target]}.exs" + +config :logger, backends: [RingLogger] + +config :logger, RingLogger, max_size: 100 diff --git a/fw/mix.exs b/fw/mix.exs index e3b4a4e..09814bf 100644 --- a/fw/mix.exs +++ b/fw/mix.exs @@ -45,6 +45,7 @@ defmodule Fw.Mixfile do {:poison, "~> 3.1"}, {:timex, "~> 3.0"}, {:aps, path: "../aps"}, + {:ring_logger, "~> 0.4"}, {:ui, path: "../ui"} ] ++ deps(@target) end diff --git a/fw/mix.lock.host b/fw/mix.lock.host index d0ffc3f..b2be89b 100644 --- a/fw/mix.lock.host +++ b/fw/mix.lock.host @@ -34,6 +34,7 @@ "pummpcomm": {:hex, :pummpcomm, "2.5.1", "e02e1c24b4e0929fe44e5cd52ea4546220008f66140de09ebf6f5af50f8101cd", [:mix], [{:rfm69, "~> 0.2", [hex: :rfm69, repo: "hexpm", optional: false]}, {:subg_rfspy, "~> 2.0", [hex: :subg_rfspy, repo: "hexpm", optional: false]}, {:timex, "~> 3.0", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm"}, "ranch": {:hex, :ranch, "1.3.2", "e4965a144dc9fbe70e5c077c65e73c57165416a901bd02ea899cfd95aa890986", [:rebar3], [], "hexpm"}, "rfm69": {:hex, :rfm69, "0.2.0", "ab6582881d2108439c79e85c731216fd1e7dc7c1c627b595960950388f8c42ba", [:mix], [{:elixir_ale, "~> 1.0", [hex: :elixir_ale, repo: "hexpm", optional: false]}], "hexpm"}, + "ring_logger": {:hex, :ring_logger, "0.4.1", "db972365bfda705288d7629e80af5704a1aafdbe9da842712c3cdd587639c72e", [:mix], [], "hexpm"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], [], "hexpm"}, "subg_rfspy": {:hex, :subg_rfspy, "2.0.0", "404cabf89eeb139cd6f43ce0ff597d20832393933aa492ca2b1ff73c846ab816", [:mix], [{:csv, "~> 2.0.0", [hex: :csv, repo: "hexpm", optional: false]}, {:elixir_ale, "~> 1.0.1", [hex: :elixir_ale, repo: "hexpm", optional: false]}, {:nerves_uart, "~> 0.1.1", [hex: :nerves_uart, repo: "hexpm", optional: false]}], "hexpm"}, "timex": {:hex, :timex, "3.2.1", "639975eac45c4c08c2dbf7fc53033c313ff1f94fad9282af03619a3826493612", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"}, diff --git a/ui/assets/js/socket.js b/ui/assets/js/socket.js index 1aba26d..385de6e 100644 --- a/ui/assets/js/socket.js +++ b/ui/assets/js/socket.js @@ -2,4 +2,14 @@ import {Socket} from "phoenix"; let socket = new Socket("/socket", {params: {}}); // token: window.userToken}}); socket.connect(); + +let channel = socket.channel("logs", {}); +channel.join() + .receive("ok", resp => { console.log("Joined successfully to logs channel", resp); }) + .receive("error", resp => { console.log("Unable to join to logs channel", resp); }); + +channel.on("log:new", msg => { + console.log("New log message:", msg); + // Things to do when receiving a log message +}); export default socket; diff --git a/ui/lib/ui.ex b/ui/lib/ui.ex index 7b2f95b..069f311 100644 --- a/ui/lib/ui.ex +++ b/ui/lib/ui.ex @@ -5,13 +5,15 @@ defmodule InfinityAPS.UI do alias InfinityAPS.UI.GlucoseBroker alias InfinityAPS.UI.Endpoint + alias InfinityAPS.UI.Client def start(_type, _args) do import Supervisor.Spec children = [ supervisor(Endpoint, []), - GlucoseBroker.child_spec(nil) + GlucoseBroker.child_spec(nil), + Client ] opts = [strategy: :one_for_one, name: InfinityAPS.UI.Supervisor] diff --git a/ui/lib/ui/client.ex b/ui/lib/ui/client.ex new file mode 100644 index 0000000..413134a --- /dev/null +++ b/ui/lib/ui/client.ex @@ -0,0 +1,279 @@ +defmodule InfinityAPS.UI.Client do + use GenServer + + alias InfinityAPS.UI.Endpoint + alias Logger.Formatter + + @moduledoc """ + Interact with the RingLogger + """ + + alias RingLogger.Server + + defmodule State do + @moduledoc false + defstruct io: nil, + colors: nil, + metadata: nil, + format: nil, + level: nil, + index: 0 + end + + @doc """ + Start up a client GenServer. Except for just getting the contents of the ring buffer, you'll + need to create one of these. See `configure/2` for information on options. + """ + def start_link(config \\ []) do + GenServer.start_link(__MODULE__, config, name: __MODULE__) + end + + @doc """ + Stop a client. + """ + def stop do + GenServer.stop(__MODULE__) + end + + @doc """ + Update the client configuration. + + Options include: + * `:io` - Defaults to `:stdio` + * `:colors` - + * `:metadata` - A KV list of additional metadata + * `:format` - A custom format string, or a {module, function} tuple (see + https://hexdocs.pm/logger/master/Logger.html#module-custom-formatting) + * `:level` - The minimum log level to report. + """ + @spec configure([RingLogger.client_option()]) :: :ok + def configure(config) do + GenServer.call(__MODULE__, {:config, config}) + end + + @doc """ + Attach the current IEx session to the logger. It will start printing log messages. + """ + @spec attach() :: :ok + def attach do + GenServer.call(__MODULE__, :attach) + end + + @doc """ + Detach the current IEx session from the logger. + """ + @spec detach() :: :ok + def detach do + GenServer.call(__MODULE__, :detach) + end + + @doc """ + Tail the messages in the log. + """ + @spec tail() :: :ok + def tail do + GenServer.call(__MODULE__, :tail) + end + + @doc """ + Reset the index into the log for `tail/1` to the oldest entry. + """ + @spec reset() :: :ok + def reset do + GenServer.call(__MODULE__, :reset) + end + + @doc """ + Helper method for formatting log messages per the current client's + configuration. + """ + @spec format(RingLogger.entry()) :: :ok + def format(message) do + GenServer.call(__MODULE__, {:format, message}) + end + + @doc """ + Run a regular expression on each entry in the log and print out the matchers. + """ + @spec grep(Regex.t()) :: :ok + def grep(regex) do + GenServer.call(__MODULE__, {:grep, regex}) + end + + def init(config) do + state = %State{ + io: Keyword.get(config, :io, :stdio), + colors: configure_colors(config), + metadata: config |> Keyword.get(:metadata, []) |> configure_metadata(), + format: config |> Keyword.get(:format) |> configure_formatter(), + level: Keyword.get(config, :level, :debug) + } + + {:ok, state} + end + + def handle_info({:log, msg}, state) do + maybe_send(msg, state) + {:noreply, state} + end + + def handle_call({:config, config}, _from, state) do + new_io = Keyword.get(config, :io, state.io) + new_level = Keyword.get(config, :level, state.level) + + new_state = %State{state | io: new_io, level: new_level} + + {:reply, :ok, new_state} + end + + def handle_call(:attach, _from, state) do + {:reply, Server.attach_client(self()), state} + end + + def handle_call(:detach, _from, state) do + {:reply, Server.detach_client(self()), state} + end + + def handle_call(:tail, _from, state) do + messages = Server.get(state.index) + + case List.last(messages) do + nil -> + # No messages + {:reply, :ok, state} + + last_message -> + Enum.each(messages, fn msg -> maybe_send(msg, state) end) + next_index = message_index(last_message) + 1 + {:reply, :ok, %{state | index: next_index}} + end + end + + def handle_call(:reset, _from, state) do + {:reply, :ok, %{state | index: 0}} + end + + def handle_call({:grep, regex}, _from, state) do + Server.get() + |> Enum.each(fn msg -> maybe_send(msg, regex, state) end) + + {:reply, :ok, state} + end + + def handle_call({:format, msg}, _from, state) do + item = format_message(msg, state) + {:reply, item, state} + end + + defp message_index({_level, {_, _msg, _ts, md}}), do: Keyword.get(md, :index) + + defp format_message({level, {_, msg, ts, md}}, state) do + metadata = take_metadata(md, state.metadata) + + """ + Note: A log can be converted to a map with a single string (as done here) + or they can be a map with multiple fields (commented out). + + {d, t} = ts + date = Logger.Formatter.format_date(d) + |> IO.chardata_to_string + time = Logger.Formatter.format_time(t) + |> IO.chardata_to_string + state.format + |> apply_format(level, msg, ts, metadata) + %{level: level |> Atom.to_string, msg: msg |> IO.chardata_to_string, date: date, time: time, metadata: metadata} + """ + + state.format + |> apply_format(level, msg, ts, metadata) + |> IO.chardata_to_string() + |> String.trim() + end + + ## Helpers + + defp apply_format({mod, fun}, level, msg, ts, metadata) do + apply(mod, fun, [level, msg, ts, metadata]) + end + + defp apply_format(format, level, msg, ts, metadata) do + Formatter.format(format, level, msg, ts, metadata) + end + + defp configure_metadata(:all), do: :all + defp configure_metadata(metadata), do: Enum.reverse(metadata) + + defp configure_colors(config) do + colors = Keyword.get(config, :colors, []) + + %{ + debug: Keyword.get(colors, :debug, :cyan), + info: Keyword.get(colors, :info, :normal), + warn: Keyword.get(colors, :warn, :yellow), + error: Keyword.get(colors, :error, :red), + enabled: Keyword.get(colors, :enabled, IO.ANSI.enabled?()) + } + end + + defp meet_level?(_lvl, nil), do: true + + defp meet_level?(lvl, min) do + Logger.compare_levels(lvl, min) != :lt + end + + defp take_metadata(metadata, :all), do: metadata + + defp take_metadata(metadata, keys) do + Enum.reduce(keys, [], fn key, acc -> + case Keyword.fetch(metadata, key) do + {:ok, val} -> [{key, val} | acc] + :error -> acc + end + end) + end + + defp color_event(data, _level, %{enabled: false}, _md), do: data + + defp color_event(data, level, %{enabled: true} = colors, md) do + color = md[:ansi_color] || Map.fetch!(colors, level) + [IO.ANSI.format_fragment(color, true), data | IO.ANSI.reset()] + end + + defp configure_formatter({mod, fun}), do: {mod, fun} + + defp configure_formatter(format) do + Formatter.compile(format) + end + + defp maybe_print({level, _} = msg, state) do + if meet_level?(level, state.level) do + item = format_message(msg, state) + IO.binwrite(state.io, item) + end + end + + defp maybe_print({level, {_, text, _, _}} = msg, r, state) do + flattened_text = IO.iodata_to_binary(text) + + if meet_level?(level, state.level) && Regex.match?(r, flattened_text) do + item = format_message(msg, state) + IO.binwrite(state.io, item) + end + end + + defp maybe_send({level, _} = msg, state) do + if meet_level?(level, state.level) do + item = format_message(msg, state) + Endpoint.broadcast!("logs", "log:new", %{msg: item}) + end + end + + defp maybe_send({level, {_, text, _, _}} = msg, r, state) do + flattened_text = IO.iodata_to_binary(text) + + if meet_level?(level, state.level) && Regex.match?(r, flattened_text) do + item = format_message(msg, state) + Endpoint.broadcast!("logs", "log:new", %{msg: item}) + end + end +end diff --git a/ui/lib/ui_web/channels/log_channel.ex b/ui/lib/ui_web/channels/log_channel.ex new file mode 100644 index 0000000..2344f4d --- /dev/null +++ b/ui/lib/ui_web/channels/log_channel.ex @@ -0,0 +1,18 @@ +defmodule InfinityAPS.UI.LogChannel do + @moduledoc false + + use Phoenix.Channel + + alias InfinityAPS.UI.Client + + def join("logs", _message, socket) do + send(self(), {:after_join_attach}) + {:ok, socket} + end + + def handle_info({:after_join_attach}, socket) do + Client.tail() + Client.attach() + {:noreply, socket} + end +end diff --git a/ui/lib/ui_web/channels/user_socket.ex b/ui/lib/ui_web/channels/user_socket.ex index c58f75a..48ced41 100644 --- a/ui/lib/ui_web/channels/user_socket.ex +++ b/ui/lib/ui_web/channels/user_socket.ex @@ -2,6 +2,7 @@ defmodule InfinityAPS.UI.UserSocket do use Phoenix.Socket channel("loop_status:*", InfinityAPS.UI.LoopStatusChannel) + channel("logs", InfinityAPS.UI.LogChannel) transport(:websocket, Phoenix.Transports.WebSocket)