diff --git a/augie/Dockerfile.template b/augie/Dockerfile.template index 2a2e0e7..b4fd62c 100644 --- a/augie/Dockerfile.template +++ b/augie/Dockerfile.template @@ -9,7 +9,7 @@ WORKDIR /app RUN mix deps.get RUN mix deps.compile -FROM balenalib/%%BALENA_MACHINE_NAME%%-alpine-node:latest as assets +FROM balenalib/%%BALENA_MACHINE_NAME%%-alpine-node:latest-build as assets COPY --from=deps /app /app COPY assets /app/assets WORKDIR /app diff --git a/augie/assets/webpack.config.js b/augie/assets/webpack.config.js index b1dcf8e..0145e2a 100644 --- a/augie/assets/webpack.config.js +++ b/augie/assets/webpack.config.js @@ -34,7 +34,7 @@ module.exports = (env, options) => ({ ] }, plugins: [ - new MiniCssExtractPlugin({ filename: '../css/app.scss' }), + new MiniCssExtractPlugin({ filename: '../css/app.css' }), new CopyWebpackPlugin([{ from: 'static/', to: '../' }]) ] }); diff --git a/augie/lib/augie/application.ex b/augie/lib/augie/application.ex index 11ae520..1257cc4 100644 --- a/augie/lib/augie/application.ex +++ b/augie/lib/augie/application.ex @@ -19,19 +19,20 @@ defmodule Augie.Application do AugieWeb.Endpoint, # Starts a worker by calling: Augie.Worker.start_link(arg) # {Augie.Worker, arg}, - Augie.UpdateLockManager, Augie.DistributedSupervisor, - Augie.Collector.RaspberryPi + Augie.UpdateLockManager, + Augie.Collector.RaspberryPi, + Augie.Collector.ErlangSystem, + Augie.Collector.ErlangMemory ] children = if enable_mdns?(), - do: - children ++ - [ - Augie.MdnsInterfaceWatcher, - {Cluster.Supervisor, [topologies, [name: Augie.ClusterSupervisor]]} - ], + do: [ + Augie.MdnsInterfaceWatcher, + {Cluster.Supervisor, [topologies, [name: Augie.ClusterSupervisor]]} + | children + ], else: children # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/augie/lib/augie/collector.ex b/augie/lib/augie/collector.ex index 6eebef5..46301a5 100644 --- a/augie/lib/augie/collector.ex +++ b/augie/lib/augie/collector.ex @@ -24,6 +24,17 @@ defmodule Augie.Collector do # config :augie, Augie.Collector, sample_gc_freq: 1000 @default_sample_gc_freq 1_000 + @doc """ + Check if the collector is up and running - sometimes it takes a while to start + due to Swarm syncs. + """ + @spec alive?() :: bool + def alive? do + __MODULE__ + |> Swarm.whereis_name() + |> is_pid() + end + @doc """ Store a record (or records) for a given device at the current time. Devices and metrics are created dynamically. diff --git a/augie/lib/augie/collector/cluster.ex b/augie/lib/augie/collector/cluster.ex index 22e7272..41de563 100644 --- a/augie/lib/augie/collector/cluster.ex +++ b/augie/lib/augie/collector/cluster.ex @@ -72,6 +72,6 @@ defmodule Augie.Collector.Cluster do end defp update_metadata(nodes) do - Collector.metadata(__MODULE__, nodes: nodes, collector: __MODULE__) + Collector.metadata(__MODULE__, nodes: nodes, collector: __MODULE__, name: "Cluster Health") end end diff --git a/augie/lib/augie/collector/erlang_memory.ex b/augie/lib/augie/collector/erlang_memory.ex new file mode 100644 index 0000000..94a6772 --- /dev/null +++ b/augie/lib/augie/collector/erlang_memory.ex @@ -0,0 +1,52 @@ +defmodule Augie.Collector.ErlangMemory do + alias Augie.Collector + use GenServer + + @moduledoc """ + This module retrieves information about the Erlang system it is running on. + """ + + @update_interval 5_000 + + @doc false + def start_link(args), do: GenServer.start_link(__MODULE__, [args]) + + @impl true + def init(_args) do + Collector.metadata({__MODULE__, node()}, metadata()) + {:ok, timer} = :timer.send_interval(@update_interval, :tick) + {:ok, %{timer: timer, count: 0}} + end + + @impl true + def terminate(_reason, %{timer: timer}) do + :timer.cancel(timer) + :ok + end + + @impl true + def handle_info(:tick, %{count: 3} = state) do + Collector.record({__MODULE__, node()}, collect_stats()) + Collector.metadata({__MODULE__, node()}, metadata()) + {:noreply, state} + end + + def handle_info(:tick, %{count: count} = state) do + Collector.record({__MODULE__, node()}, collect_stats()) + {:noreply, %{state | count: count + 1}} + end + + defp collect_stats() do + ExErlstats.memory() + |> Enum.into([]) + |> Enum.sort_by(&elem(&1, 0)) + end + + defp metadata() do + [ + name: "Erlang Memory", + collector: __MODULE__, + node: node() + ] + end +end diff --git a/augie/lib/augie/collector/erlang_system.ex b/augie/lib/augie/collector/erlang_system.ex new file mode 100644 index 0000000..1c6bda7 --- /dev/null +++ b/augie/lib/augie/collector/erlang_system.ex @@ -0,0 +1,57 @@ +defmodule Augie.Collector.ErlangSystem do + alias Augie.Collector + use GenServer + + @moduledoc """ + This module retrieves information about the Erlang system it is running on. + """ + + @update_interval 5_000 + + @doc false + def start_link(args), do: GenServer.start_link(__MODULE__, [args]) + + @impl true + def init(_args) do + Collector.metadata({__MODULE__, node()}, metadata()) + {:ok, timer} = :timer.send_interval(@update_interval, :tick) + {:ok, %{timer: timer, count: 0}} + end + + @impl true + def terminate(_reason, %{timer: timer}) do + :timer.cancel(timer) + :ok + end + + @impl true + def handle_info(:tick, %{count: 3} = state) do + Collector.record({__MODULE__, node()}, collect_stats()) + Collector.metadata({__MODULE__, node()}, metadata()) + {:noreply, state} + end + + def handle_info(:tick, %{count: count} = state) do + Collector.record({__MODULE__, node()}, collect_stats()) + {:noreply, %{state | count: count + 1}} + end + + defp collect_stats() do + sys_info = + ExErlstats.system_info() + |> Map.take(~w[port_count process_count schedulers_online]a) + + ExErlstats.stats() + |> Map.take(~w[run_queue, total_active_tasks total_run_queue_lengths]a) + |> Map.merge(sys_info) + |> Enum.into([]) + |> Enum.sort_by(&elem(&1, 0)) + end + + defp metadata() do + ExErlstats.system_info() + |> Map.take(~w[otp_release port_limit process_limit schedulers version]a) + |> Map.merge(%{name: "Erlang System", collector: __MODULE__, node: node()}) + |> Enum.into([]) + end +end diff --git a/augie/lib/augie/collector/raspberry_pi.ex b/augie/lib/augie/collector/raspberry_pi.ex index 6bf5253..fd83d19 100644 --- a/augie/lib/augie/collector/raspberry_pi.ex +++ b/augie/lib/augie/collector/raspberry_pi.ex @@ -7,7 +7,7 @@ defmodule Augie.Collector.RaspberryPi do the collector. Mostly using the `vcgencmd` utility. """ - @update_frequency 5_000 + @update_interval 5_000 @vcgencmd_stats [ temperature: "measure_temp", arm_clock: "measure_clock arm", @@ -17,25 +17,14 @@ defmodule Augie.Collector.RaspberryPi do sdram_p_voltage: "measure_volts sdram_p" ] - @doc false - def child_spec(_) do - %{ - id: {__MODULE__, hostname()}, - start: {GenServer, :start_link, [__MODULE__, []]}, - restart: :permanent, - type: :worker - } - end - @doc false def start_link(args), do: GenServer.start_link(__MODULE__, [args]) @impl true def init(_args) do - hostname = hostname() - Collector.metadata({__MODULE__, hostname}, metadata()) - {:ok, timer} = :timer.send_interval(@update_frequency, :tick) - {:ok, %{hostname: hostname, timer: timer}} + Collector.metadata({__MODULE__, node()}, metadata()) + {:ok, timer} = :timer.send_interval(@update_interval, :tick) + {:ok, %{timer: timer, count: 0}} end @impl true @@ -45,11 +34,17 @@ defmodule Augie.Collector.RaspberryPi do end @impl true - def handle_info(:tick, %{hostname: hostname} = state) do - Collector.record({__MODULE__, hostname}, collect_stats()) + def handle_info(:tick, %{count: 3} = state) do + Collector.record({__MODULE__, node()}, collect_stats()) + Collector.metadata({__MODULE__, node()}, metadata()) {:noreply, state} end + def handle_info(:tick, %{count: count} = state) do + Collector.record({__MODULE__, node()}, collect_stats()) + {:noreply, %{state | count: count + 1}} + end + defp collect_stats do @vcgencmd_stats |> Enum.reduce([], fn {name, command}, stats -> @@ -92,7 +87,8 @@ defmodule Augie.Collector.RaspberryPi do model: read_file("/sys/firmware/devicetree/base/model", "Unknown model"), serial_number: read_file("/sys/firmware/devicetree/base/serial-number"), memory: total_memory(), - collector: __MODULE__ + collector: __MODULE__, + name: "Raspberry Pi Status" ] end diff --git a/augie/lib/augie/mdns_cluster_strategy.ex b/augie/lib/augie/mdns_cluster_strategy.ex index 025316c..664332d 100644 --- a/augie/lib/augie/mdns_cluster_strategy.ex +++ b/augie/lib/augie/mdns_cluster_strategy.ex @@ -13,7 +13,7 @@ defmodule Augie.MdnsClusterStrategy do @service_domain :"_erlang._tcp.local" @host_domain "_ppp.local" - @update_frequency 10_000 + @update_interval 10_000 @doc false def start_link(args), do: GenServer.start_link(__MODULE__, args) @@ -24,7 +24,7 @@ defmodule Augie.MdnsClusterStrategy do :ok = Mdns.EventManager.register() :timer.sleep(100) advertise_services() - {:ok, timer} = :timer.send_interval(@update_frequency, :tick) + {:ok, timer} = :timer.send_interval(@update_interval, :tick) send(self(), :tick) {:ok, %{seen: [], state: state, timer: timer}} end diff --git a/augie/lib/augie/mdns_interface_watcher.ex b/augie/lib/augie/mdns_interface_watcher.ex index e3351e7..770d702 100644 --- a/augie/lib/augie/mdns_interface_watcher.ex +++ b/augie/lib/augie/mdns_interface_watcher.ex @@ -17,14 +17,14 @@ defmodule Augie.MdnsInterfaceWatcher do """ @default_interface "eth0" - @update_frequency 10_000 + @update_interval 10_000 def start_link(_), do: GenServer.start_link(__MODULE__, []) @impl true def init(_) do interface = System.get_env("MDNS_INTERFACE", @default_interface) - {:ok, timer} = :timer.send_interval(@update_frequency, :tick) + {:ok, timer} = :timer.send_interval(@update_interval, :tick) send(self(), :tick) {:ok, %{interface: interface, timer: timer, ip_address: nil, server_running: false}} end diff --git a/augie/lib/augie/update_lock_manager.ex b/augie/lib/augie/update_lock_manager.ex index 977ccb4..7275fda 100644 --- a/augie/lib/augie/update_lock_manager.ex +++ b/augie/lib/augie/update_lock_manager.ex @@ -23,22 +23,16 @@ defmodule Augie.UpdateLockManager do @lockfile_location "/tmp/balena/updates.lock" # How frequently to message the other peer(s) and decide whom should be the lock holder. - @update_frequency 10_000 + @update_interval 10_000 @doc false - def start_link(args) do - with {:ok, pid} <- GenServer.start_link(__MODULE__, [args]), - :yes <- Swarm.register_name({__MODULE__, node()}, pid), - :ok <- Swarm.join(__MODULE__, pid) do - {:ok, pid} - end - end + def start_link(args), do: GenServer.start_link(__MODULE__, [args]) @impl true def init(_args) do if File.exists?(@lockfile_location), do: File.rmdir(@lockfile_location) Process.flag(:trap_exit, true) - {:ok, timer} = :timer.send_interval(@update_frequency, :tick) + {:ok, timer} = :timer.send_interval(@update_interval, :tick) now = DateTime.utc_now() {:ok, %State{timer: timer, started_at: now}} end @@ -66,7 +60,7 @@ defmodule Augie.UpdateLockManager do |> Enum.reject(fn {_, %{last_seen: d}} -> now = DateTime.utc_now() |> DateTime.to_unix() last_seen_at = d |> DateTime.to_unix() - now - last_seen_at > @update_frequency * 3 + now - last_seen_at > @update_interval * 3 end) |> Enum.into(%{}) diff --git a/augie/lib/augie_web/live/dashboard_live.ex b/augie/lib/augie_web/live/dashboard_live.ex new file mode 100644 index 0000000..4e143fb --- /dev/null +++ b/augie/lib/augie_web/live/dashboard_live.ex @@ -0,0 +1,56 @@ +defmodule AugieWeb.DashboardLive do + use Phoenix.LiveView + alias Augie.Collector + + def render(assigns) do + ~L""" + <%= if @collector_alive do %> +
+ <%= for device <- @devices do %> +
+ <%= live_render(@socket, AugieWeb.DeviceMetricLive, id: Base.encode64(:erlang.term_to_binary(device)), session: %{id: device}) %> +
+ <% end %> +
+ <% else %> +
+

Collector not running

+ +

+ The statistics collector is not running, so there are not stats to show you. This probably means that the machine is still coming up. +

+
+ <% end %> + """ + end + + def mount(_, socket) do + if Collector.alive?() do + if connected?(socket), do: Collector.subscribe_to_new_devices() + {:ok, devices} = Collector.devices() + + {:ok, assign(socket, devices: device_sorter(devices), collector_alive: true)} + else + if connected?(socket), do: Process.send_after(self(), :collector_check, 250) + {:ok, assign(socket, devices: [], collector_alive: false)} + end + end + + def handle_info(:collector_check, socket) do + {:ok, socket} = mount(nil, socket) + {:noreply, socket} + end + + def handle_info({:new_device, device_id}, socket) do + devices = [device_id | socket.assigns.devices] + {:noreply, assign(socket, devices: device_sorter(devices))} + end + + defp device_sorter(devices) do + devices + |> Enum.sort_by(fn + {module, id} when is_atom(module) -> {module, id} + module when is_atom(module) -> {module, 0} + end) + end +end diff --git a/augie/lib/augie_web/live/device_metric_live.ex b/augie/lib/augie_web/live/device_metric_live.ex new file mode 100644 index 0000000..ca2083e --- /dev/null +++ b/augie/lib/augie_web/live/device_metric_live.ex @@ -0,0 +1,79 @@ +defmodule AugieWeb.DeviceMetricLive do + use Phoenix.LiveView + alias Augie.Collector + + def render(assigns) do + ~L""" +
+
+

<%= Keyword.get(@metadata, :name, "Unknown Device") %>

+
+ <%= if Enum.any?(@metadata) do %> + + + + + + + + <%= for {name, value} <- Keyword.drop(@metadata, ~w[name collector]a) do %> + + + + + <% end %> + +
Metadata
<%= to_string name %> + <%= if is_list(value) do %> + <%= Enum.join(value, ", ") %> + <% else %> + <%= to_string value %> + <% end %> +
+ <% end %> + <%= if Enum.any?(@last) do %> + + + + + + + + <%= for {name, {sample, _}} <- @last do %> + + + + + <% end %> + +
Metrics
<%= to_string name %><%= sample %>
+ <% end %> +
+ """ + end + + def mount(%{id: device_id}, socket) do + if connected?(socket), do: Collector.subscribe_to_device(device_id) + + {:ok, status} = Collector.status(device_id) + {:ok, assign(socket, Enum.into(status, %{}))} + end + + def handle_info({:metric, _key, metric_name, value, sample_time}, socket) do + last = + socket.assigns.last + |> Keyword.put(metric_name, {value, sample_time}) + |> Enum.sort_by(&elem(&1, 0)) + + {:noreply, assign(socket, %{last: last})} + end + + def handle_info({:metadata, _key, name, value}, socket) do + metadata = + socket.assigns.metadata + |> Keyword.put(name, value) + |> Enum.sort_by(&elem(&1, 0)) + + {:noreply, assign(socket, %{metadata: metadata})} + end +end diff --git a/augie/lib/augie_web/router.ex b/augie/lib/augie_web/router.ex index ccd37e8..430148e 100644 --- a/augie/lib/augie_web/router.ex +++ b/augie/lib/augie_web/router.ex @@ -18,6 +18,7 @@ defmodule AugieWeb.Router do pipe_through(:browser) get("/", PageController, :index) + live("/dashboard", DashboardLive) end # Other scopes may use custom stacks. diff --git a/augie/lib/augie_web/templates/layout/app.html.eex b/augie/lib/augie_web/templates/layout/app.html.eex index c2af89a..e58e8cb 100644 --- a/augie/lib/augie_web/templates/layout/app.html.eex +++ b/augie/lib/augie_web/templates/layout/app.html.eex @@ -8,7 +8,35 @@ "/> - <%= render @view_module, @view_template, assigns %> +
+
+ +
+
+ +
+
+
+ <%= if get_flash(@conn, :info) do %> +
+ <%= get_flash(@conn, :info) %> +
+ <% end %> + <%= if get_flash(@conn, :error) do %> +
+ <%= get_flash(@conn, :error) %> +
+ <% end %> +
+
+ + <%= render @view_module, @view_template, assigns %> +
diff --git a/augie/lib/augie_web/views/layout_view.ex b/augie/lib/augie_web/views/layout_view.ex index 10e251b..7029b89 100644 --- a/augie/lib/augie_web/views/layout_view.ex +++ b/augie/lib/augie_web/views/layout_view.ex @@ -1,3 +1,7 @@ defmodule AugieWeb.LayoutView do use AugieWeb, :view + + def app_version() do + Application.spec(:augie, :vsn) + end end