diff --git a/server/lib/server_web/live/host_detail_live.ex b/server/lib/server_web/live/host_detail_live.ex new file mode 100644 index 0000000..c70881f --- /dev/null +++ b/server/lib/server_web/live/host_detail_live.ex @@ -0,0 +1,258 @@ +defmodule ServerWeb.HostDetailLive do + use ServerWeb, :live_view + + alias Server.{Metrics, Repo, Schema.Host} + + @impl true + def mount(%{"name" => name}, _session, socket) do + case Repo.get_by(Host, name: name) do + nil -> + {:ok, + socket + |> put_flash(:error, "Host not found") + |> push_navigate(to: ~p"/")} + + %Host{} = host -> + if connected?(socket), + do: Phoenix.PubSub.subscribe(Server.PubSub, "metrics:#{host.id}") + + {:ok, socket |> assign(:host, host) |> load_samples()} + end + end + + @impl true + def handle_info({:metric_inserted, _host_id, _interval}, socket) do + {:noreply, load_samples(socket)} + end + + defp load_samples(socket) do + host_id = socket.assigns.host.id + + assign(socket, + fast: Metrics.latest_sample(host_id, "fast"), + medium: Metrics.latest_sample(host_id, "medium"), + slow: Metrics.latest_sample(host_id, "slow") + ) + end + + @impl true + def render(assigns) do + ~H""" +
+
+
+ <.link navigate={~p"/"} class="text-sm text-zinc-500 hover:text-zinc-900">← Back +

{@host.name}

+

+ {sys_line(@slow)} · Uptime {uptime(@fast)} · Last seen {last_seen(@host.last_seen_at)} +

+
+ status_class(@host.status)}> + {@host.status} + +
+ +
+

Host metrics

+ <.metric_row label="Load (1/5/15)" value={host_load(@fast)} /> + <.metric_row label="Memory" value={host_mem(@fast)} /> +
+ +
+

ZFS pools

+

No data.

+
+
+ {pool["name"]} + {pool["health"]} +
+
+ Capacity {pool["capacity_percent"]}% · Fragmentation {pool["fragmentation_percent"] || 0}% · Errors {pool[ + "error_count" + ] || 0} · vdevs {pool["vdev_count"] || 0} (degraded {pool["degraded_vdev_count"] || 0}) · Last scrub {pool[ + "last_scrub_end" + ] || "never"} +
+
+
+ +
+

Snapshots

+ + + + + + + + + + + + + + + + + +
DatasetCountOldestNewest
{ds["name"]}{ds["snapshot_count"]}{unix_to_date(ds["oldest_snapshot_unix"])}{unix_to_date(ds["newest_snapshot_unix"])}
+

No data.

+
+ +
+

Storage

+ + + + + + + + + + + + + + + +
NameTypeUsage
{s["name"]}{s["type"]}{storage_usage(s)}
+

No data.

+
+ +
+

VMs / LXCs

+ + + + + + + + + + + + + + + + + +
VMIDNameTypeStatus
{vm["vmid"]}{vm["name"]}{vm["type"]}{vm["status"]}
+

No data.

+
+
+ """ + end + + attr :label, :string, required: true + attr :value, :string, required: true + + def metric_row(assigns) do + ~H""" +
+ {@label} + {@value} +
+ """ + end + + defp status_class("online"), do: "text-green-600" + defp status_class("offline"), do: "text-zinc-500" + defp status_class(_), do: "text-zinc-500" + + defp pool_class("ONLINE"), do: "text-green-600 font-mono" + defp pool_class(_), do: "text-red-600 font-mono" + + defp sys_line(nil), do: "—" + + defp sys_line(%{payload: p}) do + get_in(p, ["system_info", "pve_version"]) || "—" + end + + defp uptime(nil), do: "—" + + defp uptime(%{payload: p}) do + case get_in(p, ["host", "uptime_seconds"]) do + nil -> "—" + s when is_integer(s) -> "#{div(s, 86_400)}d" + _ -> "—" + end + end + + 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 host_load(nil), do: "—" + + defp host_load(%{payload: p}) do + l1 = get_in(p, ["host", "load1"]) || "—" + l5 = get_in(p, ["host", "load5"]) || "—" + l15 = get_in(p, ["host", "load15"]) || "—" + "#{l1} / #{l5} / #{l15}" + end + + defp host_mem(nil), do: "—" + + defp host_mem(%{payload: p}) do + used = get_in(p, ["host", "mem_used_bytes"]) + total = get_in(p, ["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)}% (#{format_bytes(u)} / #{format_bytes(t)})" + + _ -> + "—" + end + end + + defp pools(nil), do: [] + defp pools(%{payload: p}), do: get_in(p, ["zfs_pools", "pools"]) || [] + + defp datasets(nil), do: [] + defp datasets(%{payload: p}), do: get_in(p, ["zfs_datasets", "datasets"]) || [] + + defp storages(nil), do: [] + defp storages(%{payload: p}), do: get_in(p, ["storage", "storages"]) || [] + + defp vms(nil), do: [] + defp vms(%{payload: p}), do: get_in(p, ["vms_runtime", "vms"]) || [] + + defp storage_usage(%{"used_bytes" => u, "total_bytes" => t}) + when is_integer(u) and is_integer(t) and t > 0 do + "#{Float.round(u / t * 100, 1)}% (#{format_bytes(u)} / #{format_bytes(t)})" + end + + defp storage_usage(_), do: "—" + + defp unix_to_date(nil), do: "—" + + defp unix_to_date(unix) when is_integer(unix) do + case DateTime.from_unix(unix) do + {:ok, dt} -> Calendar.strftime(dt, "%Y-%m-%d") + _ -> "—" + end + end + + defp format_bytes(n) when is_integer(n) do + units = ["B", "KB", "MB", "GB", "TB"] + + {val, unit} = + Enum.reduce_while(units, {n * 1.0, "B"}, fn u, {v, _} -> + if v < 1024, do: {:halt, {v, u}}, else: {:cont, {v / 1024, u}} + end) + + "#{Float.round(val, 1)} #{unit}" + end +end diff --git a/server/test/server_web/live/host_detail_live_test.exs b/server/test/server_web/live/host_detail_live_test.exs new file mode 100644 index 0000000..a561f88 --- /dev/null +++ b/server/test/server_web/live/host_detail_live_test.exs @@ -0,0 +1,84 @@ +defmodule ServerWeb.HostDetailLiveTest 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}) + + setup do + {:ok, {host, _}} = Hosts.create_host("pve-01") + {:ok, _} = Hosts.mark_online(host, "0.1.0") + + fast = %{ + "host" => %{"load1" => 0.25, "load5" => 0.3, "load15" => 0.4}, + "zfs_pools" => %{ + "pools" => [ + %{ + "name" => "rpool", + "health" => "ONLINE", + "capacity_percent" => 40, + "error_count" => 0, + "last_scrub_end" => "Sat Apr 19 02:00:00 2026" + } + ] + }, + "storage" => %{ + "storages" => [ + %{"name" => "local", "type" => "dir", "used_bytes" => 10, "total_bytes" => 100} + ] + }, + "vms_runtime" => %{ + "vms" => [%{"vmid" => 100, "name" => "nginx", "type" => "qemu", "status" => "running"}] + } + } + + medium = %{ + "zfs_datasets" => %{ + "datasets" => [ + %{ + "name" => "rpool/data", + "snapshot_count" => 2, + "newest_snapshot_unix" => 1_745_193_600, + "oldest_snapshot_unix" => 1_745_107_200 + } + ] + }, + "vms_detail" => %{"vms" => []} + } + + slow = %{ + "system_info" => %{ + "pve_version" => "pve-manager/8.3.1", + "zfs_version" => "zfs-2.3.0", + "pending_updates" => 0 + } + } + + {:ok, _} = Metrics.record_sample(host.id, "fast", DateTime.utc_now(), fast) + {:ok, _} = Metrics.record_sample(host.id, "medium", DateTime.utc_now(), medium) + {:ok, _} = Metrics.record_sample(host.id, "slow", DateTime.utc_now(), slow) + + %{host: host} + end + + test "renders sections for metrics, pools, snapshots, storage, VMs", %{ + conn: conn, + host: host + } do + {:ok, _view, html} = live(auth(conn), ~p"/hosts/#{host.name}") + + assert html =~ "pve-01" + assert html =~ "pve-manager/8.3.1" + assert html =~ "rpool" + assert html =~ "ONLINE" + assert html =~ "nginx" + assert html =~ "rpool/data" + assert html =~ "local" + end + + test "404 for unknown host", %{conn: conn} do + assert {:error, {:live_redirect, %{to: "/"}}} = + live(auth(conn), ~p"/hosts/unknown") + end +end