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

1889 lines
56 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 8090%, 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
<!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**
```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"""
<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**
```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"""
<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**
```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"""
<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**
```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"""
<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**
```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='<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:
```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/<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.