refactor(ui): minimalistic utilitarian redesign across all views

New design language:
  - dark background, system sans for UI, monospace for data
  - single green accent, amber/red for warn/critical
  - square-bordered panels + tables, no rounded cards or shadows
  - status conveyed via left-border on overview cards + badges

Changes:
  - new app.css defines CSS vars + component classes (.panel, .tbl,
    .card, .btn, .input, .badge with [data-status=*])
  - new ServerWeb.DashboardNav function component for a shared top nav
    with active-link highlighting; replaces per-view navigation clutter
  - strip the Phoenix welcome scaffold (logo, version badge, twitter/GH
    links) from layouts/app.html.heex; leaves only flash + content
  - root.html.heex title suffix switched to 'Proxmox Monitor', body
    loses the Tailwind-white background
  - rewrite render/1 in all four LiveViews + login template to use the
    new classes; admin form now uses <.form for={@form}> and properly
    clears on success
  - login page redesigned to a single tight panel matching the rest

All 58 tests still pass; 'mix compile --warnings-as-errors' is clean.
This commit is contained in:
Carsten 2026-04-22 10:18:46 +02:00
parent 1b031ecdc3
commit 50676a7cb8
9 changed files with 649 additions and 364 deletions

View file

@ -2,4 +2,311 @@
@import "tailwindcss/components";
@import "tailwindcss/utilities";
/* This file is for your main application CSS */
/* -------------------------------------------------------------------------- *
* Minimal utilitarian dashboard.
* Dark background, system sans for UI, monospace for data.
* Status conveyed via borders and small badges, no gradients or shadows.
* -------------------------------------------------------------------------- */
:root {
--bg: #0f1115;
--panel: #171a21;
--panel-2: #1d2029;
--border: #2a2f3a;
--border-strong: #3a414f;
--fg: #e6e9ef;
--fg-bright: #ffffff;
--muted: #8a93a4;
--dim: #6b7280;
--ok: #7dd3a0;
--warn: #e8b764;
--crit: #e56b6b;
--offline: #6b7280;
--link: #8fb4d9;
--accent: var(--ok);
--mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, sans-serif;
}
html, body {
background: var(--bg);
color: var(--fg);
font-family: var(--sans);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
margin: 0;
}
a { color: var(--link); text-decoration: none; }
a:hover { text-decoration: underline; }
code, pre, .mono { font-family: var(--mono); }
h1, h2, h3 { font-weight: 600; color: var(--fg-bright); margin: 0; }
h1 { font-size: 1.25rem; letter-spacing: -0.01em; }
h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted); font-weight: 600; }
h3 { font-size: 0.95rem; }
hr { border: 0; border-top: 1px solid var(--border); margin: 0; }
/* --- nav bar -------------------------------------------------------------- */
.nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.7rem 1.2rem;
border-bottom: 1px solid var(--border);
background: var(--bg);
font-size: 0.9rem;
}
.nav .brand {
color: var(--fg-bright);
font-weight: 600;
letter-spacing: 0.03em;
}
.nav .links { display: flex; gap: 1.1rem; align-items: center; }
.nav .links a { color: var(--muted); }
.nav .links a.active, .nav .links a:hover { color: var(--fg-bright); text-decoration: none; }
.nav .spacer { flex: 1; }
.nav .signout { color: var(--muted); font-size: 0.85rem; }
/* --- page container ------------------------------------------------------- */
.page {
max-width: 1200px;
margin: 0 auto;
padding: 1.2rem;
display: flex;
flex-direction: column;
gap: 1.2rem;
}
.page .pagehead {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
}
.page .pagehead .sub {
color: var(--muted);
font-size: 0.85rem;
font-family: var(--mono);
}
/* --- panel ---------------------------------------------------------------- */
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 2px;
}
.panel > header {
padding: 0.55rem 0.9rem;
border-bottom: 1px solid var(--border);
color: var(--muted);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.1em;
display: flex;
justify-content: space-between;
align-items: center;
}
.panel > .body {
padding: 0.9rem;
}
.panel > .body.tight { padding: 0; }
/* --- table ---------------------------------------------------------------- */
.tbl {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.tbl th, .tbl td {
text-align: left;
padding: 0.45rem 0.9rem;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.tbl th {
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
font-size: 0.72rem;
letter-spacing: 0.08em;
background: var(--panel-2);
}
.tbl td .mono, .tbl td.mono { font-family: var(--mono); color: var(--fg-bright); }
.tbl tr:last-child td { border-bottom: none; }
.tbl tr:hover td { background: var(--panel-2); }
.tbl td.right { text-align: right; }
.tbl td.num { font-variant-numeric: tabular-nums; font-family: var(--mono); }
/* --- buttons / inputs ----------------------------------------------------- */
.btn {
display: inline-flex;
align-items: center;
padding: 0.35rem 0.9rem;
border: 1px solid var(--border-strong);
background: var(--panel-2);
color: var(--fg);
font-family: var(--sans);
font-size: 0.85rem;
cursor: pointer;
border-radius: 2px;
line-height: 1.2;
}
.btn:hover { background: var(--border); color: var(--fg-bright); }
.btn.primary { border-color: var(--ok); color: var(--ok); }
.btn.primary:hover { background: rgba(125, 211, 160, 0.12); color: var(--ok); }
.btn.danger { border-color: var(--crit); color: var(--crit); }
.btn.danger:hover { background: rgba(229, 107, 107, 0.1); color: var(--crit); }
.btn.ghost { border-color: transparent; background: transparent; color: var(--muted); }
.btn.ghost:hover { color: var(--fg); background: var(--panel-2); }
.btn.sm { padding: 0.15rem 0.5rem; font-size: 0.75rem; }
.input {
display: block;
width: 100%;
padding: 0.4rem 0.7rem;
background: var(--bg);
border: 1px solid var(--border-strong);
color: var(--fg);
font-family: var(--mono);
font-size: 0.9rem;
border-radius: 2px;
outline: none;
}
.input:focus { border-color: var(--link); }
.input::placeholder { color: var(--dim); }
/* --- form rows ------------------------------------------------------------ */
.form-row {
display: flex;
gap: 0.5rem;
align-items: stretch;
}
.form-row .input { flex: 1; }
/* --- status badges / ampel ----------------------------------------------- */
.badge {
display: inline-block;
font-family: var(--mono);
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 0.1rem 0.45rem;
border-radius: 2px;
border: 1px solid currentColor;
}
.badge[data-status="ok"], .status-ok { color: var(--ok); }
.badge[data-status="warning"], .status-warn { color: var(--warn); }
.badge[data-status="critical"], .status-crit { color: var(--crit); }
.badge[data-status="offline"], .badge[data-status="never_connected"], .status-offline { color: var(--offline); }
.badge[data-status="online"] { color: var(--ok); }
/* --- host cards (overview grid) ----------------------------------------- */
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0.9rem;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-left: 3px solid var(--offline);
border-radius: 2px;
padding: 0.9rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
cursor: pointer;
text-decoration: none;
color: inherit;
}
.card:hover { background: var(--panel-2); text-decoration: none; }
.card[data-status="ok"] { border-left-color: var(--ok); }
.card[data-status="warning"] { border-left-color: var(--warn); }
.card[data-status="critical"] { border-left-color: var(--crit); }
.card[data-status="offline"] { border-left-color: var(--offline); opacity: 0.7; }
.card .name { font-family: var(--mono); font-weight: 600; color: var(--fg-bright); font-size: 1rem; }
.card .seen { color: var(--muted); font-size: 0.75rem; }
.card .stat { display: flex; justify-content: space-between; font-size: 0.8rem; }
.card .stat .k { color: var(--muted); }
.card .stat .v { font-family: var(--mono); color: var(--fg); }
/* --- detail rows --------------------------------------------------------- */
.kv {
display: grid;
grid-template-columns: 140px 1fr;
gap: 0.5rem 1rem;
font-size: 0.85rem;
}
.kv dt { color: var(--muted); }
.kv dd { margin: 0; font-family: var(--mono); color: var(--fg-bright); }
.pool-row {
display: flex;
justify-content: space-between;
padding: 0.45rem 0;
border-bottom: 1px solid var(--border);
gap: 0.8rem;
font-size: 0.85rem;
}
.pool-row:last-child { border-bottom: none; }
.pool-row .details { color: var(--muted); font-family: var(--mono); font-size: 0.78rem; }
/* --- callouts ------------------------------------------------------------ */
.callout {
border: 1px solid var(--border);
border-left: 3px solid var(--warn);
padding: 0.5rem 0.8rem;
background: var(--panel-2);
font-size: 0.85rem;
color: var(--warn);
font-family: var(--mono);
word-break: break-all;
}
.callout.err { border-left-color: var(--crit); color: var(--crit); }
.callout.ok { border-left-color: var(--ok); color: var(--ok); }
/* --- utilities ----------------------------------------------------------- */
.muted { color: var(--muted); }
.dim { color: var(--dim); }
.stack { display: flex; flex-direction: column; gap: 0.9rem; }
.stack.sm { gap: 0.45rem; }
.row { display: flex; gap: 0.6rem; align-items: center; }
.row.wrap { flex-wrap: wrap; }
.right { margin-left: auto; }
.center { text-align: center; }
.empty {
padding: 1.5rem;
text-align: center;
color: var(--muted);
font-size: 0.85rem;
}
/* Login --------------------------------------------------------------------*/
.login {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.login .panel { width: 100%; max-width: 22rem; }
.login .panel .body { padding: 1.2rem; display: flex; flex-direction: column; gap: 0.8rem; }
.login .brand { color: var(--fg-bright); font-weight: 600; letter-spacing: 0.03em; font-size: 1rem; }
/* Flash --------------------------------------------------------------------*/
.flash {
padding: 0.5rem 1rem;
border-left: 3px solid var(--warn);
background: var(--panel-2);
color: var(--warn);
font-size: 0.85rem;
margin: 0 1.2rem;
}
.flash.err { border-left-color: var(--crit); color: var(--crit); }
.flash.info { border-left-color: var(--link); color: var(--link); }

View file

@ -0,0 +1,26 @@
defmodule ServerWeb.DashboardNav do
@moduledoc "Top navigation bar rendered inside authenticated LiveViews."
use Phoenix.Component
use ServerWeb, :verified_routes
attr :active, :atom, required: true, values: [:overview, :vms, :admin, :other]
def nav(assigns) do
~H"""
<nav class="nav">
<span class="brand">proxmox-monitor</span>
<span class="links">
<.link navigate={~p"/"} class={link_cls(@active, :overview)}>overview</.link>
<.link navigate={~p"/vms"} class={link_cls(@active, :vms)}>vms</.link>
<.link navigate={~p"/admin/hosts"} class={link_cls(@active, :admin)}>admin</.link>
</span>
<span class="spacer"></span>
<.link href={~p"/logout"} method="delete" class="signout">sign out</.link>
</nav>
"""
end
defp link_cls(active, key) when active == key, do: "active"
defp link_cls(_, _), do: nil
end

View file

@ -1,32 +1,7 @@
<header class="px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between border-b border-zinc-100 py-3 text-sm">
<div class="flex items-center gap-4">
<a href="/">
<img src={~p"/images/logo.svg"} width="36" />
</a>
<p class="bg-brand/5 text-brand rounded-full px-2 font-medium leading-6">
v<%= Application.spec(:phoenix, :vsn) %>
</p>
</div>
<div class="flex items-center gap-4 font-semibold leading-6 text-zinc-900">
<a href="https://twitter.com/elixirphoenix" class="hover:text-zinc-700">
@elixirphoenix
</a>
<a href="https://github.com/phoenixframework/phoenix" class="hover:text-zinc-700">
GitHub
</a>
<a
href="https://hexdocs.pm/phoenix/overview.html"
class="rounded-lg bg-zinc-100 px-2 py-1 hover:bg-zinc-200/80"
>
Get Started <span aria-hidden="true">&rarr;</span>
</a>
</div>
</div>
</header>
<main class="px-4 py-20 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl">
<.flash_group flash={@flash} />
<%= @inner_content %>
</div>
</main>
<%= if info = Phoenix.Flash.get(@flash, :info) do %>
<div class="flash info">{info}</div>
<% end %>
<%= if err = Phoenix.Flash.get(@flash, :error) do %>
<div class="flash err">{err}</div>
<% end %>
{@inner_content}

View file

@ -1,17 +1,17 @@
<!DOCTYPE html>
<html lang="en" class="[scrollbar-gutter:stable]">
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<.live_title suffix=" · Phoenix Framework">
<%= assigns[:page_title] || "Server" %>
<.live_title suffix=" · Proxmox Monitor">
{assigns[:page_title] || "Dashboard"}
</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
</script>
</head>
<body class="bg-white">
<%= @inner_content %>
<body>
{@inner_content}
</body>
</html>

View file

@ -7,36 +7,30 @@
<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>
<body>
<div class="login">
<div class="panel">
<header><span>proxmox-monitor</span><span class="mono dim">sign in</span></header>
<div class="body">
<%= if @error do %>
<div class="callout err">{@error}</div>
<% end %>
<form method="post" action="/login" class="stack sm">
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
<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"
autocomplete="current-password"
placeholder="password"
class="input"
/>
</label>
<button
type="submit"
class="w-full rounded-md bg-zinc-900 text-white py-2 hover:bg-zinc-700"
>
Sign in
</button>
</form>
<button type="submit" class="btn primary" style="justify-content: center;">
sign in
</button>
</form>
</div>
</div>
</div>
</body>

View file

@ -7,9 +7,13 @@ defmodule ServerWeb.AdminHostsLive do
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:hosts, Hosts.list_all())
|> assign(:new_token, nil)
|> assign(:error, nil)}
|> assign(
hosts: Hosts.list_all(),
form: to_form(%{"name" => ""}, as: :host),
new_token: nil,
error: nil,
page_title: "Admin"
)}
end
@impl true
@ -18,9 +22,12 @@ defmodule ServerWeb.AdminHostsLive do
{:ok, {host, token}} ->
{:noreply,
socket
|> assign(:hosts, Hosts.list_all())
|> assign(:new_token, %{name: host.name, token: token})
|> assign(:error, nil)}
|> assign(
hosts: Hosts.list_all(),
form: to_form(%{"name" => ""}, as: :host),
new_token: %{name: host.name, token: token},
error: nil
)}
{:error, cs} ->
{:noreply, assign(socket, :error, changeset_message(cs))}
@ -33,8 +40,7 @@ defmodule ServerWeb.AdminHostsLive do
{:noreply,
socket
|> assign(:hosts, Hosts.list_all())
|> assign(:new_token, %{name: host.name, token: token})}
|> assign(hosts: Hosts.list_all(), new_token: %{name: host.name, token: token})}
end
def handle_event("delete", %{"id" => id}, socket) do
@ -44,89 +50,93 @@ defmodule ServerWeb.AdminHostsLive do
end
defp changeset_message(cs) do
cs.errors
|> Enum.map_join(", ", fn {k, {msg, _}} -> "#{k}: #{msg}" end)
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>
<ServerWeb.DashboardNav.nav active={:admin} />
<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 class="page">
<div class="pagehead">
<h1>Admin · Hosts</h1>
<span class="sub">{length(@hosts)} registered</span>
</div>
<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 class="panel">
<header><span>Register a host</span></header>
<div class="body">
<.form for={@form} phx-submit="create" class="form-row">
<input
name="host[name]"
value=""
placeholder="pve-hostname"
required
autocomplete="off"
class="input"
/>
<button type="submit" class="btn primary">Add</button>
</.form>
<p :if={@error} class="callout err" style="margin-top: 0.7rem;">{@error}</p>
<div :if={@new_token} class="callout ok" style="margin-top: 0.7rem;">
Token for <strong class="mono">{@new_token.name}</strong> shown once:<br/>
<code class="mono" style="color: var(--fg-bright); user-select: all;">{@new_token.token}</code>
</div>
</div>
</section>
</div>
<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 class="panel">
<header><span>All hosts</span></header>
<div class="body tight">
<table class="tbl" :if={@hosts != []}>
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Agent</th>
<th>Last seen</th>
<th class="right">Actions</th>
</tr>
</thead>
<tbody>
<tr :for={h <- @hosts}>
<td class="mono">
<.link navigate={~p"/hosts/#{h.name}"}>{h.name}</.link>
</td>
<td><span class="badge" data-status={h.status}>{h.status}</span></td>
<td class="mono">{h.agent_version || ""}</td>
<td class="mono">{format_seen(h.last_seen_at)}</td>
<td class="right">
<button
type="button"
phx-click="rotate"
phx-value-id={h.id}
class="btn sm ghost"
data-confirm={"Rotate token for #{h.name}? Old token stops working immediately."}
>
rotate
</button>
<button
type="button"
phx-click="delete"
phx-value-id={h.id}
class="btn sm danger"
data-confirm={"Delete #{h.name} and all its metrics?"}
>
delete
</button>
</td>
</tr>
</tbody>
</table>
<div :if={@hosts == []} class="empty">No hosts yet.</div>
</div>
</div>
</div>
"""
end
defp format_seen(nil), do: "never"
defp format_seen(%DateTime{} = dt) do
Calendar.strftime(dt, "%Y-%m-%d %H:%M UTC")
end
defp format_seen(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M UTC")
end

View file

@ -16,7 +16,7 @@ defmodule ServerWeb.HostDetailLive do
if connected?(socket),
do: Phoenix.PubSub.subscribe(Server.PubSub, "metrics:#{host.id}")
{:ok, socket |> assign(:host, host) |> load_samples()}
{:ok, socket |> assign(host: host, page_title: host.name) |> load_samples()}
end
end
@ -38,141 +38,129 @@ defmodule ServerWeb.HostDetailLive do
@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">
<ServerWeb.DashboardNav.nav active={:overview} />
<div class="page">
<div class="pagehead">
<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"}
<h1>{@host.name}</h1>
<div class="sub">
{sys_line(@slow)} · uptime {uptime(@fast)} · last seen {last_seen(@host.last_seen_at)}
</div>
</div>
</section>
<span class="badge" data-status={@host.status}>{@host.status}</span>
</div>
<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>
<div class="panel">
<header><span>Host</span></header>
<div class="body">
<dl class="kv">
<dt>load (1/5/15)</dt><dd>{host_load(@fast)}</dd>
<dt>memory</dt><dd>{host_mem(@fast)}</dd>
<dt>agent</dt><dd>{@host.agent_version || ""}</dd>
</dl>
</div>
</div>
<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>
<div class="panel">
<header><span>ZFS pools</span><span class="mono">{length(pools(@fast))}</span></header>
<div class="body tight">
<div :if={pools(@fast) == []} class="empty">No data.</div>
<div :for={pool <- pools(@fast)} class="pool-row" style="padding: 0.6rem 0.9rem;">
<div>
<span class="mono" style="color: var(--fg-bright); font-weight: 600;">
{pool["name"]}
</span>
<span class="badge" style={pool_badge_style(pool["health"])}>{pool["health"]}</span>
<div class="details">
cap {pool["capacity_percent"]}% ·
frag {pool["fragmentation_percent"] || 0}% ·
err {pool["error_count"] || 0} ·
vdevs {pool["vdev_count"] || 0} (deg {pool["degraded_vdev_count"] || 0})
</div>
</div>
<div class="mono muted" style="font-size: 0.75rem; align-self: center; text-align: right;">
scrub<br/>{pool["last_scrub_end"] || "never"}
</div>
</div>
</div>
</div>
<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 class="panel">
<header><span>Snapshots</span><span class="mono">{length(datasets(@medium))}</span></header>
<div class="body tight">
<table class="tbl" :if={datasets(@medium) != []}>
<thead>
<tr><th>Dataset</th><th>Count</th><th>Oldest</th><th>Newest</th></tr>
</thead>
<tbody>
<tr :for={ds <- datasets(@medium)}>
<td class="mono">{ds["name"]}</td>
<td class="num">{ds["snapshot_count"]}</td>
<td class="mono">{unix_to_date(ds["oldest_snapshot_unix"])}</td>
<td class="mono">{unix_to_date(ds["newest_snapshot_unix"])}</td>
</tr>
</tbody>
</table>
<div :if={datasets(@medium) == []} class="empty">No data.</div>
</div>
</div>
<div class="panel">
<header><span>Storage</span><span class="mono">{length(storages(@fast))}</span></header>
<div class="body tight">
<table class="tbl" :if={storages(@fast) != []}>
<thead>
<tr><th>Name</th><th>Type</th><th>Usage</th></tr>
</thead>
<tbody>
<tr :for={s <- storages(@fast)}>
<td class="mono">{s["name"]}</td>
<td>{s["type"]}</td>
<td class="mono">{storage_usage(s)}</td>
</tr>
</tbody>
</table>
<div :if={storages(@fast) == []} class="empty">No data.</div>
</div>
</div>
<div class="panel">
<header><span>VMs / LXCs</span><span class="mono">{length(vms(@fast))}</span></header>
<div class="body tight">
<table class="tbl" :if={vms(@fast) != []}>
<thead>
<tr><th>VMID</th><th>Name</th><th>Type</th><th>Status</th></tr>
</thead>
<tbody>
<tr :for={vm <- vms(@fast)}>
<td class="num">{vm["vmid"]}</td>
<td class="mono">{vm["name"]}</td>
<td>{vm["type"]}</td>
<td><span class="badge" data-status={vm_status(vm)}>{vm["status"]}</span></td>
</tr>
</tbody>
</table>
<div :if={vms(@fast) == []} class="empty">No data.</div>
</div>
</div>
</div>
"""
end
attr :label, :string, required: true
attr :value, :string, required: true
defp vm_status(%{"status" => "running"}), do: "ok"
defp vm_status(%{"status" => "stopped"}), do: "offline"
defp vm_status(_), do: "warning"
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 pool_badge_style("ONLINE"), do: "color: var(--ok);"
defp pool_badge_style(_), do: "color: var(--crit);"
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 -> ""
@ -182,10 +170,8 @@ defmodule ServerWeb.HostDetailLive do
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"
@ -194,7 +180,6 @@ defmodule ServerWeb.HostDetailLive do
end
defp host_load(nil), do: ""
defp host_load(%{payload: p}) do
l1 = get_in(p, ["host", "load1"]) || ""
l5 = get_in(p, ["host", "load5"]) || ""
@ -203,7 +188,6 @@ defmodule ServerWeb.HostDetailLive do
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"])
@ -211,9 +195,7 @@ defmodule ServerWeb.HostDetailLive do
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
@ -237,7 +219,6 @@ defmodule ServerWeb.HostDetailLive do
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")

View file

@ -6,7 +6,7 @@ defmodule ServerWeb.OverviewLive do
@impl true
def mount(_params, _session, socket) do
if connected?(socket), do: Phoenix.PubSub.subscribe(Server.PubSub, "metrics")
{:ok, assign(socket, :hosts, load_hosts())}
{:ok, assign(socket, hosts: load_hosts(), page_title: "Overview")}
end
@impl true
@ -25,70 +25,59 @@ defmodule ServerWeb.OverviewLive do
@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>
<ServerWeb.DashboardNav.nav active={:overview} />
<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 class="page">
<div class="pagehead">
<h1>Hosts</h1>
<span class="sub">{summary_line(@hosts)}</span>
</div>
<div :if={@hosts == []} class="panel">
<div class="empty">
No hosts registered. Add one at
<.link navigate={~p"/admin/hosts"}>/admin/hosts</.link>.
</div>
</div>
<p :if={@hosts == []} class="text-zinc-500">
No hosts registered yet. Add one via <code>/admin/hosts</code>.
</p>
<div class="cards">
<.link :for={entry <- @hosts} navigate={~p"/hosts/#{entry.host.name}"} class="card"
data-status={Atom.to_string(entry.status)} data-role="host-card">
<div class="row">
<span class="name">{entry.host.name}</span>
<span class="right badge" data-status={Atom.to_string(entry.status)}>
{entry.status}
</span>
</div>
<div class="seen">last seen {last_seen(entry.host.last_seen_at)}</div>
<%= if entry.sample do %>
<div class="stat"><span class="k">load</span><span class="v">{load(entry.sample.payload)}</span></div>
<div class="stat"><span class="k">ram</span><span class="v">{mem(entry.sample.payload)}</span></div>
<div class="stat"><span class="k">pools</span><span class="v">{pool_summary(entry.sample.payload)}</span></div>
<div class="stat"><span class="k">vms</span><span class="v">{vm_count(entry.sample.payload)}</span></div>
<% else %>
<div class="dim">no samples yet</div>
<% end %>
</.link>
</div>
</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 summary_line([]), do: "0 hosts"
defp summary_line(hosts) do
n = length(hosts)
by = Enum.frequencies_by(hosts, & &1.status)
parts =
[:ok, :warning, :critical, :offline]
|> Enum.filter(&Map.has_key?(by, &1))
|> Enum.map(fn s -> "#{by[s]} #{s}" end)
"#{n} host#{if n == 1, do: "", else: "s"} · " <> Enum.join(parts, " · ")
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"
@ -96,23 +85,20 @@ defmodule ServerWeb.OverviewLive do
end
end
defp format_load(payload) do
defp 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
defp 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
@ -120,11 +106,11 @@ defmodule ServerWeb.OverviewLive 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"
if total == 0, do: "", else: "#{total - bad}/#{total}"
end
defp vm_count(payload) do
vms = get_in(payload, ["vms_runtime", "vms"]) || []
length(vms)
length(vms) |> to_string()
end
end

View file

@ -6,7 +6,7 @@ defmodule ServerWeb.VmSearchLive do
@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())}
{:ok, socket |> assign(q: "", vms: load_vms(), page_title: "VM Search")}
end
@impl true
@ -54,51 +54,57 @@ defmodule ServerWeb.VmSearchLive do
@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>
<ServerWeb.DashboardNav.nav active={:vms} />
<div class="page">
<div class="pagehead">
<h1>VM Search</h1>
<span class="sub">{length(filter(@vms, @q))} / {length(@vms)} shown</span>
</div>
<form phx-change="search">
<input
name="q"
value={@q}
placeholder="Search by name or IP…"
placeholder="name or ip…"
autocomplete="off"
autofocus
class="w-full rounded-md border-zinc-300 focus:border-zinc-400 focus:ring-0"
class="input"
/>
</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 class="panel">
<div class="body tight">
<table class="tbl" :if={filter(@vms, @q) != []}>
<thead>
<tr>
<th>VMID</th>
<th>Name</th>
<th>Host</th>
<th>Type</th>
<th>Status</th>
<th>IPs</th>
</tr>
</thead>
<tbody>
<tr :for={vm <- filter(@vms, @q)}>
<td class="num">{vm.vmid}</td>
<td class="mono">{vm.name}</td>
<td><.link navigate={~p"/hosts/#{vm.host_name}"}>{vm.host_name}</.link></td>
<td>{vm.type}</td>
<td><span class="badge" data-status={vm_status(vm)}>{vm.status}</span></td>
<td class="mono" style="font-size: 0.75rem;">{Enum.join(vm.ips, ", ")}</td>
</tr>
</tbody>
</table>
<div :if={filter(@vms, @q) == []} class="empty">No matches.</div>
</div>
</div>
</div>
"""
end
defp vm_status(%{status: "running"}), do: "ok"
defp vm_status(%{status: "stopped"}), do: "offline"
defp vm_status(_), do: "warning"
end