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

@ -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")