# Phase 3 — LiveView Dashboard > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace the JSON-only view with a real, password-protected LiveView dashboard: status-ampel overview, per-host detail with ZFS/VM/storage sections, global VM search, and host admin. Samples pushed by agents propagate to open browser sessions in real time via PubSub. **Architecture:** Single-user session auth (Argon2 password hash in env var). Status-derivation is a pure function over the latest fast sample. `Server.Metrics.record_sample/4` broadcasts `{:metric_inserted, host_id}` on `Server.PubSub`; LiveViews subscribe and re-query on each event. No new persistence — everything reads from `hosts` and the `metrics` JSON payload. No charts in this phase — current values + recent history as tables (charts can ride in a later pass). **Tech Stack:** Phoenix LiveView 1.0, Phoenix.PubSub, Argon2 (`argon2_elixir`), existing Tailwind/core_components from the Phoenix 1.7 scaffold. --- ## File Structure ``` server/ ├── mix.exs modify: add argon2_elixir ├── lib/server/auth.ex create (verify_password/1) ├── lib/server/status.ex create (compute_status/1 pure fn) ├── lib/server/hosts.ex modify: list_all/0, delete_host/1, rotate_token/1 ├── lib/server/metrics.ex modify: broadcast after insert ├── lib/server_web/plugs/require_auth.ex create ├── lib/server_web/controllers/auth_controller.ex create ├── lib/server_web/controllers/auth_html/login.html.heex create ├── lib/server_web/controllers/auth_html.ex create ├── lib/server_web/live/overview_live.ex create ├── lib/server_web/live/host_detail_live.ex create ├── lib/server_web/live/vm_search_live.ex create ├── lib/server_web/live/admin_hosts_live.ex create ├── lib/server_web/router.ex modify: auth pipeline + routes ├── test/server/auth_test.exs create ├── test/server/status_test.exs create ├── test/server_web/live/overview_live_test.exs create ├── test/server_web/live/host_detail_live_test.exs create ├── test/server_web/live/vm_search_live_test.exs create └── test/server_web/live/admin_hosts_live_test.exs create ``` **Layering:** - **`Server.Auth`** — password verification against env-configured Argon2 hash. - **`Server.Status`** — `compute_status(payload) :: :ok | :warning | :critical` and `:offline` is derived from the host row, not from payload. Pure function, unit-tested. - **`Server.Hosts`** — list/delete/rotate helpers. - **`Server.Metrics`** — one-line addition: broadcast on successful insert. - **`ServerWeb.Plugs.RequireAuth`** — reads session, redirects to `/login` on miss. - **`ServerWeb.AuthController`** — login form + POST + logout. - **LiveViews** — one per page, each subscribes to `Server.PubSub` for real-time updates on relevant topics. **Status rules (concept lines 218-222):** - **critical** → ANY pool health ∈ `{DEGRADED, FAULTED, SUSPENDED, UNAVAIL}` OR any pool capacity > 90% OR host offline - **warning** → any pool capacity 80–90%, any dataset oldest-snapshot > 30 days old, any pending_updates > 0, last scrub > 35 days ago - **ok** → none of the above - **offline** → `host.status == "offline"` (takes precedence over all payload-derived states) --- ## Task 1: Auth Dependency + Config **Files:** - Modify: `server/mix.exs` - Modify: `server/config/runtime.exs` - Modify: `server/config/test.exs` - [ ] **Step 1: Add argon2_elixir** In `server/mix.exs`, extend the deps list: ```elixir {:bcrypt_elixir, "~> 3.1"}, {:argon2_elixir, "~> 4.0"} ``` - [ ] **Step 2: Fetch and compile** ```bash cd /Users/cabele/claudeprojects/proxmox_monitor/server mix deps.get && mix compile 2>&1 | tail -3 ``` Expected: argon2_elixir fetched, NIF builds successfully. - [ ] **Step 3: Wire env var into runtime config** Open `server/config/runtime.exs`. At the very top (after `import Config` and any existing code), add: ```elixir if config_env() == :prod or config_env() == :dev do hash = System.get_env("DASHBOARD_PASSWORD_HASH") || raise """ DASHBOARD_PASSWORD_HASH not set. Generate one with: mix run -e 'IO.puts(Argon2.hash_pwd_salt("your-password"))' """ config :server, :dashboard_password_hash, hash end ``` - [ ] **Step 4: Test-env Argon2 tuning** In `server/config/test.exs` add at the bottom: ```elixir config :argon2_elixir, t_cost: 1, m_cost: 8 ``` This keeps Argon2 fast in tests. The `:dashboard_password_hash` app env key is only read by `Server.Auth.verify_password/1`; the auth test sets a real hash in its own `setup`, and no other test touches auth, so there's no need for a config-file default. - [ ] **Step 5: Commit** ```bash cd /Users/cabele/claudeprojects/proxmox_monitor git add server/mix.exs server/mix.lock server/config/runtime.exs server/config/test.exs git commit -m "feat(server): argon2_elixir dep + dashboard_password_hash config" ``` --- ## Task 2: Server.Auth Module (TDD) **Files:** - Create: `server/lib/server/auth.ex` - Create: `server/test/server/auth_test.exs` - [ ] **Step 1: Failing test** Create `server/test/server/auth_test.exs`: ```elixir defmodule Server.AuthTest do use ExUnit.Case, async: true alias Server.Auth setup do hash = Argon2.hash_pwd_salt("testpass") prev = Application.get_env(:server, :dashboard_password_hash) Application.put_env(:server, :dashboard_password_hash, hash) on_exit(fn -> Application.put_env(:server, :dashboard_password_hash, prev) end) :ok end describe "verify_password/1" do test "returns :ok for correct password" do assert Auth.verify_password("testpass") == :ok end test "returns :error for wrong password" do assert Auth.verify_password("wrong") == :error end test "returns :error for non-binary input" do assert Auth.verify_password(nil) == :error assert Auth.verify_password(123) == :error end end end ``` - [ ] **Step 2: Run — expect failure** ```bash cd /Users/cabele/claudeprojects/proxmox_monitor/server mix test test/server/auth_test.exs 2>&1 | tail -5 ``` Expected: `Server.Auth` undefined. - [ ] **Step 3: Implement** Create `server/lib/server/auth.ex`: ```elixir defmodule Server.Auth do @moduledoc "Single-user dashboard authentication." @spec verify_password(term()) :: :ok | :error def verify_password(password) when is_binary(password) do hash = Application.fetch_env!(:server, :dashboard_password_hash) if Argon2.verify_pass(password, hash) do :ok else :error end end def verify_password(_), do: :error end ``` - [ ] **Step 4: Run — expect pass** ```bash mix test test/server/auth_test.exs 2>&1 | tail -5 ``` Expected: 3 tests pass. - [ ] **Step 5: Commit** ```bash cd /Users/cabele/claudeprojects/proxmox_monitor git add server/lib/server/auth.ex server/test/server/auth_test.exs git commit -m "feat(server): Server.Auth.verify_password/1" ``` --- ## Task 3: Server.Status Pure Function (TDD) **Files:** - Create: `server/lib/server/status.ex` - Create: `server/test/server/status_test.exs` - [ ] **Step 1: Failing test** Create `server/test/server/status_test.exs`: ```elixir defmodule Server.StatusTest do use ExUnit.Case, async: true alias Server.Status describe "compute/2" do test "returns :offline when host status is offline, regardless of payload" do assert Status.compute("offline", %{"zfs_pools" => %{"pools" => [healthy_pool()]}}) == :offline end test "returns :ok with all-healthy payload" do payload = %{ "zfs_pools" => %{"pools" => [healthy_pool()]}, "system_info" => %{"pending_updates" => 0} } assert Status.compute("online", payload) == :ok end test "returns :critical for degraded pool" do payload = %{"zfs_pools" => %{"pools" => [Map.put(healthy_pool(), "health", "DEGRADED")]}} assert Status.compute("online", payload) == :critical end test "returns :critical for pool capacity > 90" do payload = %{"zfs_pools" => %{"pools" => [Map.put(healthy_pool(), "capacity_percent", 95)]}} assert Status.compute("online", payload) == :critical end test "returns :warning for pool capacity 80..90" do payload = %{"zfs_pools" => %{"pools" => [Map.put(healthy_pool(), "capacity_percent", 85)]}} assert Status.compute("online", payload) == :warning end test "returns :warning for pending OS updates > 0" do payload = %{ "zfs_pools" => %{"pools" => [healthy_pool()]}, "system_info" => %{"pending_updates" => 3} } assert Status.compute("online", payload) == :warning end test "returns :ok when payload is nil (never-seen host) but host is online" do assert Status.compute("online", nil) == :ok end test "treats never_connected like offline" do assert Status.compute("never_connected", nil) == :offline end end defp healthy_pool do %{ "name" => "rpool", "health" => "ONLINE", "capacity_percent" => 40 } end end ``` - [ ] **Step 2: Run — expect failure** ```bash mix test test/server/status_test.exs 2>&1 | tail -5 ``` Expected: `Server.Status` undefined. - [ ] **Step 3: Implement** Create `server/lib/server/status.ex`: ```elixir defmodule Server.Status do @moduledoc """ Derive a status level for a host from its latest fast sample. :offline host has no active agent connection :critical pool DEGRADED/FAULTED or capacity > 90 :warning capacity 80..90 or pending OS updates :ok everything nominal """ @bad_pool_states ~w(DEGRADED FAULTED SUSPENDED UNAVAIL) @spec compute(String.t(), map() | nil) :: :offline | :critical | :warning | :ok def compute(host_status, _payload) when host_status in ~w(offline never_connected), do: :offline def compute(_host_status, nil), do: :ok def compute(_host_status, %{} = payload) do pools = get_in(payload, ["zfs_pools", "pools"]) || [] pending = get_in(payload, ["system_info", "pending_updates"]) || 0 cond do Enum.any?(pools, &critical_pool?/1) -> :critical Enum.any?(pools, &warning_pool?/1) -> :warning pending > 0 -> :warning true -> :ok end end defp critical_pool?(pool) do health = pool["health"] cap = pool["capacity_percent"] || 0 health in @bad_pool_states or cap > 90 end defp warning_pool?(pool) do cap = pool["capacity_percent"] || 0 cap >= 80 and cap <= 90 end end ``` - [ ] **Step 4: Run — expect pass** ```bash mix test test/server/status_test.exs 2>&1 | tail -5 ``` Expected: 8 tests pass. - [ ] **Step 5: Commit** ```bash cd /Users/cabele/claudeprojects/proxmox_monitor git add server/lib/server/status.ex server/test/server/status_test.exs git commit -m "feat(server): pure Status.compute/2 for ok/warning/critical/offline" ``` --- ## Task 4: Hosts Context Extensions + Metrics PubSub **Files:** - Modify: `server/lib/server/hosts.ex` - Modify: `server/lib/server/metrics.ex` - Modify: `server/test/server/hosts_test.exs` - Modify: `server/test/server/metrics_test.exs` - [ ] **Step 1: Extend `Server.Hosts` tests** Open `server/test/server/hosts_test.exs` and append these test blocks before the final `end`: ```elixir describe "list_all/0" do test "returns every host ordered by name" do {:ok, {_, _}} = Hosts.create_host("pve-02") {:ok, {_, _}} = Hosts.create_host("pve-01") names = Hosts.list_all() |> Enum.map(& &1.name) assert names == ["pve-01", "pve-02"] end end describe "delete_host/1" do test "deletes the host row" do {:ok, {host, _}} = Hosts.create_host("pve-01") assert {:ok, _} = Hosts.delete_host(host) assert Server.Repo.get(Server.Schema.Host, host.id) == nil end end describe "rotate_token/1" do test "replaces token_hash and returns new plaintext token" do {:ok, {host, old_token}} = Hosts.create_host("pve-01") assert {:ok, {updated, new_token}} = Hosts.rotate_token(host) assert updated.id == host.id refute updated.token_hash == host.token_hash assert is_binary(new_token) refute new_token == old_token assert {:error, :invalid_token} = Hosts.authenticate("pve-01", old_token) assert {:ok, _} = Hosts.authenticate("pve-01", new_token) end end ``` - [ ] **Step 2: Run — expect failure** ```bash mix test test/server/hosts_test.exs 2>&1 | tail -5 ``` Expected: undefined functions `list_all/0`, `delete_host/1`, `rotate_token/1`. - [ ] **Step 3: Extend `Server.Hosts`** Append to `server/lib/server/hosts.ex` before the closing `end`: ```elixir @spec list_all() :: [Host.t()] def list_all do import Ecto.Query Repo.all(from h in Host, order_by: [asc: h.name]) end @spec delete_host(Host.t()) :: {:ok, Host.t()} | {:error, Ecto.Changeset.t()} def delete_host(%Host{} = host), do: Repo.delete(host) @spec rotate_token(Host.t()) :: {:ok, {Host.t(), String.t()}} | {:error, Ecto.Changeset.t()} def rotate_token(%Host{} = host) do token = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) hash = Bcrypt.hash_pwd_salt(token) host |> Ecto.Changeset.change(token_hash: hash) |> Repo.update() |> case do {:ok, updated} -> {:ok, {updated, token}} {:error, cs} -> {:error, cs} end end ``` - [ ] **Step 4: Run hosts tests — expect pass** ```bash mix test test/server/hosts_test.exs 2>&1 | tail -5 ``` Expected: 10 tests pass. - [ ] **Step 5: Add PubSub broadcast to Metrics.record_sample** Open `server/lib/server/metrics.ex`. Replace the existing `record_sample/4` function (keep everything else) with: ```elixir @spec record_sample(integer(), String.t(), DateTime.t(), map()) :: {:ok, Metric.t()} | {:error, Ecto.Changeset.t()} def record_sample(host_id, interval_type, collected_at, payload) do changeset = Metric.changeset(%Metric{}, %{ host_id: host_id, interval_type: interval_type, collected_at: collected_at, payload: payload }) with %Ecto.Changeset{valid?: true} = cs <- changeset, true <- host_exists?(host_id) || {:host_missing, cs}, {:ok, metric} <- Repo.insert(cs) do Phoenix.PubSub.broadcast(Server.PubSub, "metrics", {:metric_inserted, host_id, interval_type}) Phoenix.PubSub.broadcast(Server.PubSub, "metrics:#{host_id}", {:metric_inserted, host_id, interval_type}) {:ok, metric} else %Ecto.Changeset{} = cs -> {:error, cs} {:host_missing, cs} -> {:error, Ecto.Changeset.add_error(cs, :host, "does not exist")} {:error, %Ecto.Changeset{} = cs} -> {:error, cs} end end ``` - [ ] **Step 6: Add a PubSub assertion to metrics tests** In `server/test/server/metrics_test.exs` append within the `describe "record_sample/4" do` block (before its closing `end`): ```elixir test "broadcasts {:metric_inserted, host_id, interval} on success", %{host: host} do Phoenix.PubSub.subscribe(Server.PubSub, "metrics") ts = DateTime.utc_now() {:ok, _} = Metrics.record_sample(host.id, "fast", ts, %{"v" => 1}) assert_receive {:metric_inserted, host_id, "fast"}, 500 assert host_id == host.id end ``` - [ ] **Step 7: Run tests — expect pass** ```bash mix test 2>&1 | tail -4 ``` Expected: all green (previous tests + 1 new hosts + 1 new metrics). - [ ] **Step 8: Commit** ```bash cd /Users/cabele/claudeprojects/proxmox_monitor git add server/lib/server/hosts.ex server/lib/server/metrics.ex server/test/server/hosts_test.exs server/test/server/metrics_test.exs git commit -m "feat(server): hosts list/delete/rotate helpers + pubsub on metric insert" ``` --- ## Task 5: Auth Plug + Session Controller **Files:** - Create: `server/lib/server_web/plugs/require_auth.ex` - Create: `server/lib/server_web/controllers/auth_controller.ex` - Create: `server/lib/server_web/controllers/auth_html.ex` - Create: `server/lib/server_web/controllers/auth_html/login.html.heex` - [ ] **Step 1: Require-auth plug** Create `server/lib/server_web/plugs/require_auth.ex`: ```elixir defmodule ServerWeb.Plugs.RequireAuth do @moduledoc "Redirects to /login unless the session is authenticated." import Plug.Conn import Phoenix.Controller def init(opts), do: opts def call(conn, _opts) do if get_session(conn, :authenticated) do conn else conn |> put_flash(:error, "Please sign in.") |> redirect(to: "/login") |> halt() end end end ``` - [ ] **Step 2: AuthController** Create `server/lib/server_web/controllers/auth_controller.ex`: ```elixir defmodule ServerWeb.AuthController do use ServerWeb, :controller def login(conn, _params) do render(conn, :login, error: nil, layout: false) end def create(conn, %{"password" => password}) do case Server.Auth.verify_password(password) do :ok -> conn |> configure_session(renew: true) |> put_session(:authenticated, true) |> redirect(to: "/") :error -> conn |> put_status(:unauthorized) |> render(:login, error: "Incorrect password.", layout: false) end end def delete(conn, _params) do conn |> configure_session(drop: true) |> redirect(to: "/login") end end ``` - [ ] **Step 3: Auth HTML module (empty — uses embed_templates)** Create `server/lib/server_web/controllers/auth_html.ex`: ```elixir defmodule ServerWeb.AuthHTML do use ServerWeb, :html embed_templates "auth_html/*" end ``` - [ ] **Step 4: Login template** Create `server/lib/server_web/controllers/auth_html/login.html.heex`: ```heex Sign in · Proxmox Monitor

Proxmox Monitor

<%= if @error do %>

{@error}

<% end %>
``` - [ ] **Step 5: Compile to verify no syntax errors** ```bash mix compile 2>&1 | tail -5 ``` Expected: compiles, no warnings. - [ ] **Step 6: Commit** ```bash cd /Users/cabele/claudeprojects/proxmox_monitor git add server/lib/server_web/plugs server/lib/server_web/controllers/auth_controller.ex server/lib/server_web/controllers/auth_html.ex server/lib/server_web/controllers/auth_html git commit -m "feat(server): session-based auth plug + login controller/template" ``` --- ## Task 6: Router — Auth Pipeline + Login/Logout Routes **Files:** - Modify: `server/lib/server_web/router.ex` - [ ] **Step 1: Introduce auth pipeline and wire routes** Replace the contents of `server/lib/server_web/router.ex` with: ```elixir defmodule ServerWeb.Router do use ServerWeb, :router pipeline :browser do plug :accepts, ["html"] plug :fetch_session plug :fetch_live_flash plug :put_root_layout, html: {ServerWeb.Layouts, :root} plug :protect_from_forgery plug :put_secure_browser_headers end pipeline :require_auth do plug ServerWeb.Plugs.RequireAuth end pipeline :api do plug :accepts, ["json"] end # Public login/logout scope "/", ServerWeb do pipe_through :browser get "/login", AuthController, :login post "/login", AuthController, :create delete "/logout", AuthController, :delete end # Authenticated dashboard (LiveView) scope "/", ServerWeb do pipe_through [:browser, :require_auth] live_session :authenticated, on_mount: {ServerWeb.LiveAuth, :require_authenticated} do live "/", OverviewLive, :index live "/hosts/:name", HostDetailLive, :show live "/vms", VmSearchLive, :index live "/admin/hosts", AdminHostsLive, :index end end scope "/api", ServerWeb do pipe_through :api get "/hosts/:name", HostController, :show end if Application.compile_env(:server, :dev_routes) do import Phoenix.LiveDashboard.Router scope "/dev" do pipe_through :browser live_dashboard "/dashboard", metrics: ServerWeb.Telemetry end end end ``` - [ ] **Step 2: Create `ServerWeb.LiveAuth` — the `on_mount` hook LiveViews use to enforce auth** Create `server/lib/server_web/live_auth.ex`: ```elixir defmodule ServerWeb.LiveAuth do @moduledoc "on_mount hook for LiveView sessions requiring authentication." import Phoenix.LiveView import Phoenix.Component, only: [assign: 3] def on_mount(:require_authenticated, _params, session, socket) do if session["authenticated"] do {:cont, assign(socket, :authenticated, true)} else {:halt, redirect(socket, to: "/login")} end end end ``` - [ ] **Step 3: Replace the default root page** Phoenix 1.7 scaffold provides `PageController.home/2`. Our router above replaces `/` with `OverviewLive`, which doesn't exist yet. Compile should still succeed — Phoenix only checks live route modules at request time. Verify: ```bash mix compile 2>&1 | tail -5 ``` Expected: clean compile (warnings about unused `ServerWeb.PageController` are OK — we'll leave the file alone for now). - [ ] **Step 4: Commit** ```bash cd /Users/cabele/claudeprojects/proxmox_monitor git add server/lib/server_web/router.ex server/lib/server_web/live_auth.ex git commit -m "feat(server): router pipelines + live_auth hook for authenticated dashboard" ``` --- ## Task 7: Overview LiveView **Files:** - Create: `server/lib/server_web/live/overview_live.ex` - Create: `server/test/server_web/live/overview_live_test.exs` - [ ] **Step 1: Tests** Create `server/test/server_web/live/overview_live_test.exs`: ```elixir 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" # at least two cards visible assert length(Floki.find(Floki.parse_document!(html), "[data-role=host-card]")) == 2 _ = h1 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) # Allow the PubSub message to round-trip Process.sleep(50) assert render(view) =~ ~r/data-status=\"critical\"/ end end end ``` - [ ] **Step 2: Run — expect failure (`OverviewLive` undefined)** ```bash mix test test/server_web/live/overview_live_test.exs 2>&1 | tail -5 ``` - [ ] **Step 3: Implement OverviewLive** Create `server/lib/server_web/live/overview_live.ex`: ```elixir 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 ``` - [ ] **Step 4: Run — expect pass** ```bash mix test test/server_web/live/overview_live_test.exs 2>&1 | tail -5 ``` Expected: 4 tests pass. - [ ] **Step 5: Commit** ```bash cd /Users/cabele/claudeprojects/proxmox_monitor git add server/lib/server_web/live/overview_live.ex server/test/server_web/live/overview_live_test.exs git commit -m "feat(server): overview LiveView with status ampel + pubsub updates" ``` --- ## Task 8: Host Detail LiveView **Files:** - Create: `server/lib/server_web/live/host_detail_live.ex` - Create: `server/test/server_web/live/host_detail_live_test.exs` - [ ] **Step 1: Tests** Create `server/test/server_web/live/host_detail_live_test.exs`: ```elixir 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 ``` - [ ] **Step 2: Run — expect failure** ```bash mix test test/server_web/live/host_detail_live_test.exs 2>&1 | tail -5 ``` - [ ] **Step 3: Implement** Create `server/lib/server_web/live/host_detail_live.ex`: ```elixir 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

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 "#{p |> get_in(["host", "load1"]) || "—"} / #{p |> get_in(["host", "load5"]) || "—"} / #{p |> get_in(["host", "load15"]) || "—"}" 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 ``` - [ ] **Step 4: Run — expect pass** ```bash mix test test/server_web/live/host_detail_live_test.exs 2>&1 | tail -5 ``` Expected: 2 tests pass. - [ ] **Step 5: Commit** ```bash cd /Users/cabele/claudeprojects/proxmox_monitor git add server/lib/server_web/live/host_detail_live.ex server/test/server_web/live/host_detail_live_test.exs git commit -m "feat(server): host detail LiveView with metrics/pools/snapshots/storage/vms" ``` --- ## Task 9: VM Search LiveView **Files:** - Create: `server/lib/server_web/live/vm_search_live.ex` - Create: `server/test/server_web/live/vm_search_live_test.exs` - [ ] **Step 1: Tests** Create `server/test/server_web/live/vm_search_live_test.exs`: ```elixir defmodule ServerWeb.VmSearchLiveTest 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, {h1, _}} = Hosts.create_host("pve-01") {:ok, {h2, _}} = Hosts.create_host("pve-02") fast1 = %{ "vms_runtime" => %{ "vms" => [ %{"vmid" => 100, "name" => "nginx-proxy", "type" => "qemu", "status" => "running"} ] } } fast2 = %{ "vms_runtime" => %{ "vms" => [ %{"vmid" => 200, "name" => "db-primary", "type" => "qemu", "status" => "running"} ] } } medium1 = %{ "vms_detail" => %{ "vms" => [%{"vmid" => 100, "name" => "nginx-proxy", "ips" => ["192.168.1.10"]}] } } {:ok, _} = Metrics.record_sample(h1.id, "fast", DateTime.utc_now(), fast1) {:ok, _} = Metrics.record_sample(h2.id, "fast", DateTime.utc_now(), fast2) {:ok, _} = Metrics.record_sample(h1.id, "medium", DateTime.utc_now(), medium1) :ok end test "lists all VMs from all hosts by default", %{conn: conn} do {:ok, _view, html} = live(auth(conn), "/vms") assert html =~ "nginx-proxy" assert html =~ "db-primary" end test "filters by name substring", %{conn: conn} do {:ok, view, _html} = live(auth(conn), "/vms") html = view |> form("form", q: "nginx") |> render_change() assert html =~ "nginx-proxy" refute html =~ "db-primary" end test "filters by IP substring (matches detail payload)", %{conn: conn} do {:ok, view, _html} = live(auth(conn), "/vms") html = view |> form("form", q: "192.168.1") |> render_change() assert html =~ "nginx-proxy" refute html =~ "db-primary" end end ``` - [ ] **Step 2: Run — expect failure** ```bash mix test test/server_web/live/vm_search_live_test.exs 2>&1 | tail -5 ``` - [ ] **Step 3: Implement** Create `server/lib/server_web/live/vm_search_live.ex`: ```elixir defmodule ServerWeb.VmSearchLive do use ServerWeb, :live_view alias Server.{Hosts, Metrics} @impl true def mount(_params, _session, socket) do if connected?(socket), do: Phoenix.PubSub.subscribe(Server.PubSub, "metrics") {:ok, socket |> assign(:q, "") |> assign(:vms, load_vms())} end @impl true def handle_info({:metric_inserted, _, _}, socket) do {:noreply, assign(socket, :vms, load_vms())} end @impl true def handle_event("search", %{"q" => q}, socket) do {:noreply, assign(socket, :q, q)} end defp load_vms do for host <- Hosts.list_all(), runtime_sample = Metrics.latest_sample(host.id, "fast"), detail_sample = Metrics.latest_sample(host.id, "medium"), vm <- get_in(runtime_sample && runtime_sample.payload, ["vms_runtime", "vms"]) || [], into: [] do detail_vms = get_in(detail_sample && detail_sample.payload, ["vms_detail", "vms"]) || [] detail_vm = Enum.find(detail_vms, &(&1["vmid"] == vm["vmid"])) || %{} ips = detail_vm["ips"] || [] %{ vmid: vm["vmid"], name: vm["name"], type: vm["type"], status: vm["status"], host_name: host.name, ips: ips } end end defp filter(vms, ""), do: vms defp filter(vms, q) do q = String.downcase(q) Enum.filter(vms, fn vm -> String.contains?(String.downcase(vm.name || ""), q) or Enum.any?(vm.ips, &String.contains?(&1, q)) end) end @impl true def render(assigns) do ~H"""
<.link navigate={~p"/"} class="text-sm text-zinc-500 hover:text-zinc-900">← Back

VM Search

Name Host Type Status IPs
{vm.name} <.link navigate={~p"/hosts/#{vm.host_name}"} class="text-zinc-700 hover:text-zinc-900 underline"> {vm.host_name} {vm.type} {vm.status} {Enum.join(vm.ips, ", ")}
No matches.
""" end end ``` - [ ] **Step 4: Run — expect pass** ```bash mix test test/server_web/live/vm_search_live_test.exs 2>&1 | tail -5 ``` Expected: 3 tests pass. - [ ] **Step 5: Commit** ```bash cd /Users/cabele/claudeprojects/proxmox_monitor git add server/lib/server_web/live/vm_search_live.ex server/test/server_web/live/vm_search_live_test.exs git commit -m "feat(server): vm search LiveView with name+IP filtering" ``` --- ## Task 10: Admin Hosts LiveView **Files:** - Create: `server/lib/server_web/live/admin_hosts_live.ex` - Create: `server/test/server_web/live/admin_hosts_live_test.exs` - [ ] **Step 1: Tests** Create `server/test/server_web/live/admin_hosts_live_test.exs`: ```elixir defmodule ServerWeb.AdminHostsLiveTest do use ServerWeb.ConnCase, async: false import Phoenix.LiveViewTest alias Server.Hosts defp auth(conn), do: Plug.Test.init_test_session(conn, %{authenticated: true}) test "lists hosts", %{conn: conn} do {:ok, {_, _}} = Hosts.create_host("pve-01") {:ok, _view, html} = live(auth(conn), "/admin/hosts") assert html =~ "pve-01" end test "creates a new host and reveals the token", %{conn: conn} do {:ok, view, _html} = live(auth(conn), "/admin/hosts") html = view |> form("form[phx-submit=create]", host: %{name: "pve-new"}) |> render_submit() assert html =~ "pve-new" assert html =~ ~r/[A-Za-z0-9_\-]{40,}/ end test "revokes token", %{conn: conn} do {:ok, {host, _}} = Hosts.create_host("pve-01") original_hash = host.token_hash {:ok, view, _html} = live(auth(conn), "/admin/hosts") _html = render_click(view, "rotate", %{"id" => to_string(host.id)}) reloaded = Server.Repo.reload!(host) refute reloaded.token_hash == original_hash end test "deletes a host", %{conn: conn} do {:ok, {host, _}} = Hosts.create_host("pve-gone") {:ok, view, _html} = live(auth(conn), "/admin/hosts") html = render_click(view, "delete", %{"id" => to_string(host.id)}) refute html =~ "pve-gone" end end ``` - [ ] **Step 2: Run — expect failure** ```bash mix test test/server_web/live/admin_hosts_live_test.exs 2>&1 | tail -5 ``` - [ ] **Step 3: Implement** Create `server/lib/server_web/live/admin_hosts_live.ex`: ```elixir defmodule ServerWeb.AdminHostsLive do use ServerWeb, :live_view alias Server.{Hosts, Repo, Schema.Host} @impl true def mount(_params, _session, socket) do {:ok, socket |> assign(:hosts, Hosts.list_all()) |> assign(:new_token, nil) |> assign(:error, nil)} end @impl true def handle_event("create", %{"host" => %{"name" => name}}, socket) do case Hosts.create_host(name) do {:ok, {host, token}} -> {:noreply, socket |> assign(:hosts, Hosts.list_all()) |> assign(:new_token, %{name: host.name, token: token}) |> assign(:error, nil)} {:error, cs} -> {:noreply, assign(socket, :error, changeset_message(cs))} end end def handle_event("rotate", %{"id" => id}, socket) do %Host{} = host = Repo.get!(Host, id) {:ok, {_, token}} = Hosts.rotate_token(host) {:noreply, socket |> assign(:hosts, Hosts.list_all()) |> assign(:new_token, %{name: host.name, token: token})} end def handle_event("delete", %{"id" => id}, socket) do %Host{} = host = Repo.get!(Host, id) {:ok, _} = Hosts.delete_host(host) {:noreply, assign(socket, :hosts, Hosts.list_all())} end defp changeset_message(cs) do cs.errors |> Enum.map_join(", ", fn {k, {msg, _}} -> "#{k}: #{msg}" end) end @impl true def render(assigns) do ~H"""
<.link navigate={~p"/"} class="text-sm text-zinc-500 hover:text-zinc-900">← Back

Hosts

Register a new host

{@error}

Token for {@new_token.name} (shown once):

{@new_token.token}
Name Status Agent Last seen Actions
{h.name} {h.status} {h.agent_version || "—"} {format_seen(h.last_seen_at)}
No hosts yet.
""" end defp format_seen(nil), do: "never" defp format_seen(%DateTime{} = dt) do Calendar.strftime(dt, "%Y-%m-%d %H:%M UTC") end end ``` - [ ] **Step 4: Run — expect pass** ```bash mix test test/server_web/live/admin_hosts_live_test.exs 2>&1 | tail -5 ``` Expected: 4 tests pass. - [ ] **Step 5: Commit** ```bash cd /Users/cabele/claudeprojects/proxmox_monitor git add server/lib/server_web/live/admin_hosts_live.ex server/test/server_web/live/admin_hosts_live_test.exs git commit -m "feat(server): admin LiveView for host registration, rotate, delete" ``` --- ## Task 11: Full Suite + Manual Smoke Test - [ ] **Step 1: Run all server tests** ```bash cd /Users/cabele/claudeprojects/proxmox_monitor/server mix test 2>&1 | tail -4 ``` Expected: all green. - [ ] **Step 2: Generate a password hash** ```bash mix run -e 'IO.puts(Argon2.hash_pwd_salt("devpass"))' ``` Copy the printed hash. - [ ] **Step 3: Start server with hash set** ```bash DASHBOARD_PASSWORD_HASH='' mix phx.server ``` Expected: `Running ServerWeb.Endpoint` log line. - [ ] **Step 4: Browser-drive the dashboard** Open http://localhost:4000/. Expected: redirect to `/login`. Enter `devpass`. Expected: redirect to `/` showing host cards. If no hosts exist, go to `/admin/hosts` and register one via the form. Copy the token. Write it to `/tmp/agent-local.toml` (same format as Phase 1/2 smoke test) with a short fast interval, and run the agent: ```bash cd /Users/cabele/claudeprojects/proxmox_monitor/agent AGENT_CONFIG=/tmp/agent-local.toml mix run --no-halt ``` Back in the browser, watch `/` — within 5-10s the card should gain Load/RAM/Pools/VMs rows and flip to green. Click the card: `/hosts/` should show all sections with live data. Navigate to `/vms`: should list the VMs; search should filter. Navigate to `/admin/hosts`: Rotate → agent disconnects (old token invalid) and status flips to offline in real time. - [ ] **Step 5: Clean up and stop services** Stop the agent (`Ctrl+C, a`) and the server (`Ctrl+C, a`). Remove `/tmp/agent-local.toml`. No code changes — no commit. --- ## Phase 3 Exit Criteria - `mix test` — all green. - Login redirect + session flow works. - Overview, Host-Detail, VM-Search, Admin pages render with data. - PubSub round-trip observed in browser (live update on metric arrival). - All commits on `main`. **Deferred to a later phase (YAGNI):** - Charts over 24h — the dashboard shows current values; historical metrics are in SQLite for future sparklines. - API auth — `/api/hosts/:name` is still public. - Export/download of payloads.