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

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