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.
56 KiB
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 | :criticaland:offlineis 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/loginon miss.ServerWeb.AuthController— login form + POST + logout.- LiveViews — one per page, each subscribes to
Server.PubSubfor 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:
{: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.Hoststests
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— theon_mounthook 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 (
OverviewLiveundefined)
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/:nameis still public. - Export/download of payloads.