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.
116 lines
3.7 KiB
Elixir
116 lines
3.7 KiB
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(), page_title: "Overview")}
|
|
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"""
|
|
<ServerWeb.DashboardNav.nav active={:overview} />
|
|
|
|
<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>
|
|
|
|
<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 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"
|
|
true -> "#{div(secs, 3600)}h ago"
|
|
end
|
|
end
|
|
|
|
defp load(payload) do
|
|
case get_in(payload, ["host", "load1"]) do
|
|
nil -> "—"
|
|
l -> :io_lib.format("~.2f", [l]) |> to_string()
|
|
end
|
|
end
|
|
|
|
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
|
|
|
|
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}"
|
|
end
|
|
|
|
defp vm_count(payload) do
|
|
vms = get_in(payload, ["vms_runtime", "vms"]) || []
|
|
length(vms) |> to_string()
|
|
end
|
|
end
|