Add foudation-based UI and a very simple collector visualisation. Also add some visualisations for niceness.
This commit is contained in:
parent
b2d11525a7
commit
5f3a433338
16 changed files with 323 additions and 44 deletions
|
@ -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
|
||||
|
|
|
@ -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: '../' }])
|
||||
]
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
52
augie/lib/augie/collector/erlang_memory.ex
Normal file
52
augie/lib/augie/collector/erlang_memory.ex
Normal 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
|
57
augie/lib/augie/collector/erlang_system.ex
Normal file
57
augie/lib/augie/collector/erlang_system.ex
Normal 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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(%{})
|
||||
|
||||
|
|
56
augie/lib/augie_web/live/dashboard_live.ex
Normal file
56
augie/lib/augie_web/live/dashboard_live.ex
Normal 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
|
79
augie/lib/augie_web/live/device_metric_live.ex
Normal file
79
augie/lib/augie_web/live/device_metric_live.ex
Normal 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
|
|
@ -18,6 +18,7 @@ defmodule AugieWeb.Router do
|
|||
pipe_through(:browser)
|
||||
|
||||
get("/", PageController, :index)
|
||||
live("/dashboard", DashboardLive)
|
||||
end
|
||||
|
||||
# Other scopes may use custom stacks.
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
defmodule AugieWeb.LayoutView do
|
||||
use AugieWeb, :view
|
||||
|
||||
def app_version() do
|
||||
Application.spec(:augie, :vsn)
|
||||
end
|
||||
end
|
||||
|
|
Reference in a new issue