diff --git a/server/lib/server_web/live/overview_live.ex b/server/lib/server_web/live/overview_live.ex new file mode 100644 index 0000000..1732f82 --- /dev/null +++ b/server/lib/server_web/live/overview_live.ex @@ -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""" +
+
+
+

Proxmox Monitor

+ <.link navigate={~p"/vms"} class="text-sm text-zinc-600 hover:text-zinc-900">VMs + <.link navigate={~p"/admin/hosts"} class="text-sm text-zinc-600 hover:text-zinc-900"> + Admin + +
+ <.link href={~p"/logout"} method="delete" class="text-sm text-zinc-500">Sign out +
+ +
+
border_class(entry.status)} + > + <.link navigate={~p"/hosts/#{entry.host.name}"} class="block space-y-2"> +
+ {entry.host.name} + text_class(entry.status)}> + {entry.status} + +
+
+ Last seen: {last_seen(entry.host.last_seen_at)} +
+
+
Load: {format_load(entry.sample.payload)}
+
RAM used: {format_mem(entry.sample.payload)}
+
Pools: {pool_summary(entry.sample.payload)}
+
VMs: {vm_count(entry.sample.payload)}
+
+
+ No samples yet +
+ +
+
+ +

+ No hosts registered yet. Add one via /admin/hosts. +

+
+ """ + 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 diff --git a/server/test/server_web/live/overview_live_test.exs b/server/test/server_web/live/overview_live_test.exs new file mode 100644 index 0000000..d80d411 --- /dev/null +++ b/server/test/server_web/live/overview_live_test.exs @@ -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