# 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"""
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"""
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)}
Rotate
Delete
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.