feat(server): overview LiveView with status ampel + pubsub updates
This commit is contained in:
parent
62996d883d
commit
d0507f290e
2 changed files with 194 additions and 0 deletions
130
server/lib/server_web/live/overview_live.ex
Normal file
130
server/lib/server_web/live/overview_live.ex
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
defmodule ServerWeb.OverviewLive do
|
||||||
|
use ServerWeb, :live_view
|
||||||
|
|
||||||
|
alias Server.{Hosts, Metrics, Status}
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
if connected?(socket), do: Phoenix.PubSub.subscribe(Server.PubSub, "metrics")
|
||||||
|
{:ok, assign(socket, :hosts, load_hosts())}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({:metric_inserted, _host_id, _interval}, socket) do
|
||||||
|
{:noreply, assign(socket, :hosts, load_hosts())}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_hosts do
|
||||||
|
for host <- Hosts.list_all() do
|
||||||
|
sample = Metrics.latest_sample(host.id, "fast")
|
||||||
|
payload = sample && sample.payload
|
||||||
|
%{host: host, sample: sample, status: Status.compute(host.status, payload)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="p-6 max-w-6xl mx-auto space-y-6">
|
||||||
|
<header class="flex justify-between items-center">
|
||||||
|
<div class="space-x-4">
|
||||||
|
<h1 class="text-2xl font-bold inline">Proxmox Monitor</h1>
|
||||||
|
<.link navigate={~p"/vms"} class="text-sm text-zinc-600 hover:text-zinc-900">VMs</.link>
|
||||||
|
<.link navigate={~p"/admin/hosts"} class="text-sm text-zinc-600 hover:text-zinc-900">
|
||||||
|
Admin
|
||||||
|
</.link>
|
||||||
|
</div>
|
||||||
|
<.link href={~p"/logout"} method="delete" class="text-sm text-zinc-500">Sign out</.link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<div
|
||||||
|
:for={entry <- @hosts}
|
||||||
|
data-role="host-card"
|
||||||
|
data-status={Atom.to_string(entry.status)}
|
||||||
|
class={"p-4 rounded-lg border-l-4 bg-white shadow-sm " <> border_class(entry.status)}
|
||||||
|
>
|
||||||
|
<.link navigate={~p"/hosts/#{entry.host.name}"} class="block space-y-2">
|
||||||
|
<div class="flex justify-between items-baseline">
|
||||||
|
<span class="font-semibold text-zinc-900">{entry.host.name}</span>
|
||||||
|
<span class={"text-xs uppercase tracking-wide " <> text_class(entry.status)}>
|
||||||
|
{entry.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-zinc-500">
|
||||||
|
Last seen: {last_seen(entry.host.last_seen_at)}
|
||||||
|
</div>
|
||||||
|
<div :if={entry.sample} class="text-sm text-zinc-700 space-y-1">
|
||||||
|
<div>Load: {format_load(entry.sample.payload)}</div>
|
||||||
|
<div>RAM used: {format_mem(entry.sample.payload)}</div>
|
||||||
|
<div>Pools: {pool_summary(entry.sample.payload)}</div>
|
||||||
|
<div>VMs: {vm_count(entry.sample.payload)}</div>
|
||||||
|
</div>
|
||||||
|
<div :if={is_nil(entry.sample)} class="text-sm text-zinc-400 italic">
|
||||||
|
No samples yet
|
||||||
|
</div>
|
||||||
|
</.link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p :if={@hosts == []} class="text-zinc-500">
|
||||||
|
No hosts registered yet. Add one via <code>/admin/hosts</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp border_class(:ok), do: "border-green-500"
|
||||||
|
defp border_class(:warning), do: "border-yellow-500"
|
||||||
|
defp border_class(:critical), do: "border-red-500"
|
||||||
|
defp border_class(:offline), do: "border-zinc-400"
|
||||||
|
|
||||||
|
defp text_class(:ok), do: "text-green-600"
|
||||||
|
defp text_class(:warning), do: "text-yellow-600"
|
||||||
|
defp text_class(:critical), do: "text-red-600"
|
||||||
|
defp text_class(:offline), do: "text-zinc-500"
|
||||||
|
|
||||||
|
defp last_seen(nil), do: "never"
|
||||||
|
|
||||||
|
defp last_seen(%DateTime{} = dt) do
|
||||||
|
secs = DateTime.diff(DateTime.utc_now(), dt, :second)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
secs < 60 -> "#{secs}s ago"
|
||||||
|
secs < 3600 -> "#{div(secs, 60)}m ago"
|
||||||
|
true -> "#{div(secs, 3600)}h ago"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_load(payload) do
|
||||||
|
case get_in(payload, ["host", "load1"]) do
|
||||||
|
nil -> "—"
|
||||||
|
l -> :io_lib.format("~.2f", [l]) |> to_string()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_mem(payload) do
|
||||||
|
used = get_in(payload, ["host", "mem_used_bytes"])
|
||||||
|
total = get_in(payload, ["host", "mem_total_bytes"])
|
||||||
|
|
||||||
|
case {used, total} do
|
||||||
|
{u, t} when is_integer(u) and is_integer(t) and t > 0 ->
|
||||||
|
"#{Float.round(u / t * 100, 1)}%"
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
"—"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pool_summary(payload) do
|
||||||
|
pools = get_in(payload, ["zfs_pools", "pools"]) || []
|
||||||
|
total = length(pools)
|
||||||
|
bad = Enum.count(pools, &(&1["health"] != "ONLINE"))
|
||||||
|
if total == 0, do: "—", else: "#{total - bad}/#{total} ok"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp vm_count(payload) do
|
||||||
|
vms = get_in(payload, ["vms_runtime", "vms"]) || []
|
||||||
|
length(vms)
|
||||||
|
end
|
||||||
|
end
|
||||||
64
server/test/server_web/live/overview_live_test.exs
Normal file
64
server/test/server_web/live/overview_live_test.exs
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
defmodule ServerWeb.OverviewLiveTest do
|
||||||
|
use ServerWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
alias Server.{Hosts, Metrics}
|
||||||
|
|
||||||
|
defp auth(conn), do: Plug.Test.init_test_session(conn, %{authenticated: true})
|
||||||
|
|
||||||
|
describe "mount" do
|
||||||
|
test "redirects to /login when unauthenticated", %{conn: conn} do
|
||||||
|
assert {:error, {:redirect, %{to: "/login"}}} = live(conn, "/")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders a card for each host", %{conn: conn} do
|
||||||
|
{:ok, {_h1, _}} = Hosts.create_host("pve-01")
|
||||||
|
{:ok, {_h2, _}} = Hosts.create_host("pve-02")
|
||||||
|
|
||||||
|
{:ok, _view, html} = live(auth(conn), "/")
|
||||||
|
|
||||||
|
assert html =~ "pve-01"
|
||||||
|
assert html =~ "pve-02"
|
||||||
|
|
||||||
|
assert length(Floki.find(Floki.parse_document!(html), "[data-role=host-card]")) == 2
|
||||||
|
end
|
||||||
|
|
||||||
|
test "reflects :critical status for a degraded pool", %{conn: conn} do
|
||||||
|
{:ok, {host, _}} = Hosts.create_host("pve-01")
|
||||||
|
{:ok, _} = Hosts.mark_online(host, "0.1.0")
|
||||||
|
|
||||||
|
payload = %{
|
||||||
|
"zfs_pools" => %{
|
||||||
|
"pools" => [%{"name" => "rpool", "health" => "DEGRADED", "capacity_percent" => 40}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, _} = Metrics.record_sample(host.id, "fast", DateTime.utc_now(), payload)
|
||||||
|
|
||||||
|
{:ok, _view, html} = live(auth(conn), "/")
|
||||||
|
|
||||||
|
assert html =~ ~r/data-status=\"critical\"/
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "pubsub" do
|
||||||
|
test "updates the card when a new metric arrives", %{conn: conn} do
|
||||||
|
{:ok, {host, _}} = Hosts.create_host("pve-01")
|
||||||
|
{:ok, _} = Hosts.mark_online(host, "0.1.0")
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(auth(conn), "/")
|
||||||
|
assert render(view) =~ ~r/data-status=\"ok\"/
|
||||||
|
|
||||||
|
payload = %{
|
||||||
|
"zfs_pools" => %{
|
||||||
|
"pools" => [%{"name" => "rpool", "health" => "DEGRADED", "capacity_percent" => 40}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, _} = Metrics.record_sample(host.id, "fast", DateTime.utc_now(), payload)
|
||||||
|
|
||||||
|
Process.sleep(50)
|
||||||
|
assert render(view) =~ ~r/data-status=\"critical\"/
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Add a link
Reference in a new issue