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"""
+
+
+
+
+ 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
+
+
+
+ | Dataset |
+ Count |
+ Oldest |
+ Newest |
+
+
+
+
+ | {ds["name"]} |
+ {ds["snapshot_count"]} |
+ {unix_to_date(ds["oldest_snapshot_unix"])} |
+ {unix_to_date(ds["newest_snapshot_unix"])} |
+
+
+
+ No data.
+
+
+
+ Storage
+
+
+
+ | Name |
+ Type |
+ Usage |
+
+
+
+
+ | {s["name"]} |
+ {s["type"]} |
+ {storage_usage(s)} |
+
+
+
+ No data.
+
+
+
+ VMs / LXCs
+
+
+
+ | VMID |
+ Name |
+ Type |
+ Status |
+
+
+
+
+ | {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