Add foudation-based UI and a very simple collector visualisation. Also add some visualisations for niceness.

This commit is contained in:
James Harton 2019-12-03 18:41:49 +13:00
parent b2d11525a7
commit 5f3a433338
16 changed files with 323 additions and 44 deletions

View file

@ -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

View file

@ -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: '../' }])
]
});

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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(%{})

View file

@ -0,0 +1,56 @@
defmodule AugieWeb.DashboardLive do
use Phoenix.LiveView
alias Augie.Collector
def render(assigns) do
~L"""
<%= if @collector_alive do %>
<div class="grid-x grid-padding-x grid-padding-y small-up-1 medium-up-2 large-up-3">
<%= for device <- @devices do %>
<div class="cell">
<%= live_render(@socket, AugieWeb.DeviceMetricLive, id: Base.encode64(:erlang.term_to_binary(device)), session: %{id: device}) %>
</div>
<% end %>
</div>
<% else %>
<div class="callout alert">
<h2>Collector not running</h2>
<p>
The statistics collector is not running, so there are not stats to show you. This probably means that the machine is still coming up.
</p>
</div>
<% 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

View file

@ -0,0 +1,79 @@
defmodule AugieWeb.DeviceMetricLive do
use Phoenix.LiveView
alias Augie.Collector
def render(assigns) do
~L"""
<div class="card">
<div class="card-divider">
<h4><%= Keyword.get(@metadata, :name, "Unknown Device") %></h4>
</div>
<%= if Enum.any?(@metadata) do %>
<table class="table card-section unstriped stack">
<thead>
<tr>
<td colspan="2">Metadata</td>
</tr>
</thead>
<tbody>
<%= for {name, value} <- Keyword.drop(@metadata, ~w[name collector]a) do %>
<tr>
<td><%= to_string name %></td>
<td>
<%= if is_list(value) do %>
<%= Enum.join(value, ", ") %>
<% else %>
<%= to_string value %>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% end %>
<%= if Enum.any?(@last) do %>
<table class="table card-section unstriped stack">
<thead>
<tr>
<td colspan="2">Metrics</td>
</tr>
</thead>
<tbody>
<%= for {name, {sample, _}} <- @last do %>
<tr>
<td><%= to_string name %></td>
<td><%= sample %></td>
</tr>
<% end %>
</tbody>
</table>
<% end %>
</div>
"""
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

View file

@ -18,6 +18,7 @@ defmodule AugieWeb.Router do
pipe_through(:browser)
get("/", PageController, :index)
live("/dashboard", DashboardLive)
end
# Other scopes may use custom stacks.

View file

@ -8,7 +8,35 @@
<link rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
</head>
<body>
<%= render @view_module, @view_template, assigns %>
<div class="top-bar">
<div class="top-bar-left">
<ul class="dropdown menu" data-dropdown-menu>
<li class="menu-text">Augie <span class="label"><%= app_version() %></span></li>
<li>
<%= link "Dashboard", to: Routes.live_path(@conn, AugieWeb.DashboardLive) %>
</li>
</ul>
</div>
</div>
<div class="grid-container">
<div class="grid-x grid-margin-x">
<div class="cell">
<%= if get_flash(@conn, :info) do %>
<div class="callout warning">
<%= get_flash(@conn, :info) %>
</div>
<% end %>
<%= if get_flash(@conn, :error) do %>
<div class="callout alert">
<%= get_flash(@conn, :error) %>
</div>
<% end %>
</div>
</div>
<%= render @view_module, @view_template, assigns %>
</div>
<script type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
</body>

View file

@ -1,3 +1,7 @@
defmodule AugieWeb.LayoutView do
use AugieWeb, :view
def app_version() do
Application.spec(:augie, :vsn)
end
end