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