proxMon/docs/superpowers/plans/2026-04-21-phase3-liveview-dashboard.md
Carsten fe7b07db4f fix(server): only require DASHBOARD_PASSWORD_HASH in prod
Blocking bootstrap in dev meant you couldn't even run 'mix run' to
generate the initial hash. Now dev/test accept an optional env override
and boot without it; prod still raises when unset.
2026-04-21 22:59:24 +02:00

56 KiB
Raw Blame History

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.Statuscompute_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 8090%, any dataset oldest-snapshot > 30 days old, any pending_updates > 0, last scrub > 35 days ago
  • ok → none of the above
  • offlinehost.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:

      {:bcrypt_elixir, "~> 3.1"},
      {:argon2_elixir, "~> 4.0"}
  • Step 2: Fetch and compile
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:

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:

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
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:

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
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:

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
mix test test/server/auth_test.exs 2>&1 | tail -5

Expected: 3 tests pass.

  • Step 5: Commit
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:

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
mix test test/server/status_test.exs 2>&1 | tail -5

Expected: Server.Status undefined.

  • Step 3: Implement

Create server/lib/server/status.ex:

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
mix test test/server/status_test.exs 2>&1 | tail -5

Expected: 8 tests pass.

  • Step 5: Commit
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:

  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
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:

  @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
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:

  @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):

    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
mix test 2>&1 | tail -4

Expected: all green (previous tests + 1 new hosts + 1 new metrics).

  • Step 8: Commit
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:

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:

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:

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:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="csrf-token" content={Phoenix.Controller.get_csrf_token()} />
    <title>Sign in · Proxmox Monitor</title>
    <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
  </head>
  <body class="bg-white">
    <div class="min-h-screen flex items-center justify-center">
      <div class="max-w-sm w-full space-y-6 p-6 border border-zinc-200 rounded-lg shadow">
        <h1 class="text-xl font-semibold text-zinc-800">Proxmox Monitor</h1>

        <%= if @error do %>
          <p class="text-sm text-red-600">{@error}</p>
        <% end %>

        <form method="post" action="/login" class="space-y-4">
          <input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />

          <label class="block">
            <span class="text-sm text-zinc-700">Password</span>
            <input
              name="password"
              type="password"
              required
              autofocus
              class="mt-1 block w-full rounded-md border-zinc-300 focus:border-zinc-400 focus:ring-0"
            />
          </label>

          <button
            type="submit"
            class="w-full rounded-md bg-zinc-900 text-white py-2 hover:bg-zinc-700"
          >
            Sign in
          </button>
        </form>
      </div>
    </div>
  </body>
</html>
  • Step 5: Compile to verify no syntax errors
mix compile 2>&1 | tail -5

Expected: compiles, no warnings.

  • Step 6: Commit
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:

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:

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:

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
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:

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)
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:

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
  • Step 4: Run — expect pass
mix test test/server_web/live/overview_live_test.exs 2>&1 | tail -5

Expected: 4 tests pass.

  • Step 5: Commit
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:

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
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:

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"""
    <div class="p-6 max-w-6xl mx-auto space-y-6">
      <header class="flex justify-between items-center">
        <div>
          <.link navigate={~p"/"} class="text-sm text-zinc-500 hover:text-zinc-900">← Back</.link>
          <h1 class="text-2xl font-bold">{@host.name}</h1>
          <p class="text-sm text-zinc-600">
            {sys_line(@slow)} · Uptime {uptime(@fast)} · Last seen {last_seen(@host.last_seen_at)}
          </p>
        </div>
        <span class={"text-xs uppercase tracking-wide " <> status_class(@host.status)}>
          {@host.status}
        </span>
      </header>

      <section class="bg-white border rounded-lg p-4">
        <h2 class="font-semibold text-zinc-800 mb-2">Host metrics</h2>
        <.metric_row label="Load (1/5/15)" value={host_load(@fast)} />
        <.metric_row label="Memory" value={host_mem(@fast)} />
      </section>

      <section class="bg-white border rounded-lg p-4">
        <h2 class="font-semibold text-zinc-800 mb-2">ZFS pools</h2>
        <p :if={pools(@fast) == []} class="text-sm text-zinc-500">No data.</p>
        <div :for={pool <- pools(@fast)} class="border-b py-2 last:border-b-0">
          <div class="flex justify-between">
            <span class="font-mono">{pool["name"]}</span>
            <span class={pool_class(pool["health"])}>{pool["health"]}</span>
          </div>
          <div class="text-sm text-zinc-600">
            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"}
          </div>
        </div>
      </section>

      <section class="bg-white border rounded-lg p-4">
        <h2 class="font-semibold text-zinc-800 mb-2">Snapshots</h2>
        <table class="w-full text-sm">
          <thead>
            <tr class="text-left text-zinc-500 border-b">
              <th class="py-1 pr-4">Dataset</th>
              <th class="py-1 pr-4">Count</th>
              <th class="py-1 pr-4">Oldest</th>
              <th class="py-1 pr-4">Newest</th>
            </tr>
          </thead>
          <tbody>
            <tr :for={ds <- datasets(@medium)} class="border-b last:border-b-0">
              <td class="py-1 font-mono">{ds["name"]}</td>
              <td class="py-1">{ds["snapshot_count"]}</td>
              <td class="py-1">{unix_to_date(ds["oldest_snapshot_unix"])}</td>
              <td class="py-1">{unix_to_date(ds["newest_snapshot_unix"])}</td>
            </tr>
          </tbody>
        </table>
        <p :if={datasets(@medium) == []} class="text-sm text-zinc-500">No data.</p>
      </section>

      <section class="bg-white border rounded-lg p-4">
        <h2 class="font-semibold text-zinc-800 mb-2">Storage</h2>
        <table class="w-full text-sm">
          <thead>
            <tr class="text-left text-zinc-500 border-b">
              <th class="py-1 pr-4">Name</th>
              <th class="py-1 pr-4">Type</th>
              <th class="py-1 pr-4">Usage</th>
            </tr>
          </thead>
          <tbody>
            <tr :for={s <- storages(@fast)} class="border-b last:border-b-0">
              <td class="py-1 font-mono">{s["name"]}</td>
              <td class="py-1">{s["type"]}</td>
              <td class="py-1">{storage_usage(s)}</td>
            </tr>
          </tbody>
        </table>
        <p :if={storages(@fast) == []} class="text-sm text-zinc-500">No data.</p>
      </section>

      <section class="bg-white border rounded-lg p-4">
        <h2 class="font-semibold text-zinc-800 mb-2">VMs / LXCs</h2>
        <table class="w-full text-sm">
          <thead>
            <tr class="text-left text-zinc-500 border-b">
              <th class="py-1 pr-4">VMID</th>
              <th class="py-1 pr-4">Name</th>
              <th class="py-1 pr-4">Type</th>
              <th class="py-1 pr-4">Status</th>
            </tr>
          </thead>
          <tbody>
            <tr :for={vm <- vms(@fast)} class="border-b last:border-b-0">
              <td class="py-1">{vm["vmid"]}</td>
              <td class="py-1 font-mono">{vm["name"]}</td>
              <td class="py-1">{vm["type"]}</td>
              <td class="py-1">{vm["status"]}</td>
            </tr>
          </tbody>
        </table>
        <p :if={vms(@fast) == []} class="text-sm text-zinc-500">No data.</p>
      </section>
    </div>
    """
  end

  attr :label, :string, required: true
  attr :value, :string, required: true

  def metric_row(assigns) do
    ~H"""
    <div class="flex justify-between py-1 border-b last:border-b-0 text-sm">
      <span class="text-zinc-500">{@label}</span>
      <span class="font-mono">{@value}</span>
    </div>
    """
  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
mix test test/server_web/live/host_detail_live_test.exs 2>&1 | tail -5

Expected: 2 tests pass.

  • Step 5: Commit
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:

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
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:

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"""
    <div class="p-6 max-w-6xl mx-auto space-y-4">
      <.link navigate={~p"/"} class="text-sm text-zinc-500 hover:text-zinc-900">← Back</.link>
      <h1 class="text-2xl font-bold">VM Search</h1>

      <form phx-change="search">
        <input
          name="q"
          value={@q}
          placeholder="Search by name or IP…"
          autofocus
          class="w-full rounded-md border-zinc-300 focus:border-zinc-400 focus:ring-0"
        />
      </form>

      <table class="w-full text-sm bg-white border rounded-lg">
        <thead>
          <tr class="text-left text-zinc-500 border-b">
            <th class="py-2 px-3">Name</th>
            <th class="py-2 px-3">Host</th>
            <th class="py-2 px-3">Type</th>
            <th class="py-2 px-3">Status</th>
            <th class="py-2 px-3">IPs</th>
          </tr>
        </thead>
        <tbody>
          <tr :for={vm <- filter(@vms, @q)} class="border-b last:border-b-0">
            <td class="py-2 px-3 font-mono">{vm.name}</td>
            <td class="py-2 px-3">
              <.link navigate={~p"/hosts/#{vm.host_name}"} class="text-zinc-700 hover:text-zinc-900 underline">
                {vm.host_name}
              </.link>
            </td>
            <td class="py-2 px-3">{vm.type}</td>
            <td class="py-2 px-3">{vm.status}</td>
            <td class="py-2 px-3 font-mono text-xs">{Enum.join(vm.ips, ", ")}</td>
          </tr>
          <tr :if={filter(@vms, @q) == []}>
            <td colspan="5" class="py-4 px-3 text-center text-zinc-500">No matches.</td>
          </tr>
        </tbody>
      </table>
    </div>
    """
  end
end
  • Step 4: Run — expect pass
mix test test/server_web/live/vm_search_live_test.exs 2>&1 | tail -5

Expected: 3 tests pass.

  • Step 5: Commit
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:

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
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:

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"""
    <div class="p-6 max-w-4xl mx-auto space-y-6">
      <.link navigate={~p"/"} class="text-sm text-zinc-500 hover:text-zinc-900">← Back</.link>
      <h1 class="text-2xl font-bold">Hosts</h1>

      <section class="bg-white border rounded-lg p-4">
        <h2 class="font-semibold mb-2">Register a new host</h2>
        <form phx-submit="create" class="flex gap-2">
          <input
            name="host[name]"
            placeholder="pve-hostname"
            required
            class="flex-1 rounded-md border-zinc-300 focus:border-zinc-400 focus:ring-0"
          />
          <button type="submit" class="rounded-md bg-zinc-900 text-white px-4">Add</button>
        </form>
        <p :if={@error} class="text-sm text-red-600 mt-2">{@error}</p>

        <div :if={@new_token} class="mt-4 p-3 bg-amber-50 border border-amber-200 rounded">
          <p class="text-sm font-semibold text-amber-900">
            Token for {@new_token.name} (shown once):
          </p>
          <code class="block mt-1 break-all text-sm">{@new_token.token}</code>
        </div>
      </section>

      <section class="bg-white border rounded-lg">
        <table class="w-full text-sm">
          <thead>
            <tr class="text-left text-zinc-500 border-b">
              <th class="py-2 px-3">Name</th>
              <th class="py-2 px-3">Status</th>
              <th class="py-2 px-3">Agent</th>
              <th class="py-2 px-3">Last seen</th>
              <th class="py-2 px-3 text-right">Actions</th>
            </tr>
          </thead>
          <tbody>
            <tr :for={h <- @hosts} class="border-b last:border-b-0">
              <td class="py-2 px-3 font-mono">{h.name}</td>
              <td class="py-2 px-3">{h.status}</td>
              <td class="py-2 px-3">{h.agent_version || "—"}</td>
              <td class="py-2 px-3">{format_seen(h.last_seen_at)}</td>
              <td class="py-2 px-3 text-right space-x-2">
                <button
                  phx-click="rotate"
                  phx-value-id={h.id}
                  class="text-xs text-zinc-700 underline"
                  data-confirm={"Rotate token for #{h.name}? Old token will stop working."}
                >
                  Rotate
                </button>
                <button
                  phx-click="delete"
                  phx-value-id={h.id}
                  class="text-xs text-red-600 underline"
                  data-confirm={"Delete #{h.name} and all its metrics?"}
                >
                  Delete
                </button>
              </td>
            </tr>
            <tr :if={@hosts == []}>
              <td colspan="5" class="py-4 px-3 text-center text-zinc-500">
                No hosts yet.
              </td>
            </tr>
          </tbody>
        </table>
      </section>
    </div>
    """
  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
mix test test/server_web/live/admin_hosts_live_test.exs 2>&1 | tail -5

Expected: 4 tests pass.

  • Step 5: Commit
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
cd /Users/cabele/claudeprojects/proxmox_monitor/server
mix test 2>&1 | tail -4

Expected: all green.

  • Step 2: Generate a password hash
mix run -e 'IO.puts(Argon2.hash_pwd_salt("devpass"))'

Copy the printed hash.

  • Step 3: Start server with hash set
DASHBOARD_PASSWORD_HASH='<paste-hash-from-step-2>' 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:

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/<name> 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.