Addresses final code review: - to_int/1 now returns 0 on nil or unparseable strings instead of crashing - remove unused .pool-row CSS (superseded by .pool-block) - clamp capacity bar width to [0, 100] to prevent visual overflow - pool_scrub_line/1 uses scan_function so resilver shows as "resilver..."
293 lines
9.4 KiB
Elixir
293 lines
9.4 KiB
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, page_title: host.name) |> 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"""
|
|
<ServerWeb.DashboardNav.nav active={:overview} />
|
|
|
|
<div class="page">
|
|
<div class="pagehead">
|
|
<div>
|
|
<h1>{@host.name}</h1>
|
|
<div class="sub">
|
|
{sys_line(@slow)} · uptime {uptime(@fast)} · last seen {last_seen(@host.last_seen_at)}
|
|
</div>
|
|
</div>
|
|
<span class="badge" data-status={@host.status}>{@host.status}</span>
|
|
</div>
|
|
|
|
<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>
|
|
|
|
<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-block">
|
|
<div class="head">
|
|
<div>
|
|
<span class="mono" style="color: var(--fg-bright); font-weight: 600;">{pool["name"]}</span>
|
|
<span class="layout">{pool_layout(pool)}</span>
|
|
</div>
|
|
<span class="badge" style={pool_badge_style(pool["health"])}>{pool["health"]}</span>
|
|
</div>
|
|
|
|
<div class="capbar" data-level={capbar_level(pool["capacity_percent"])}>
|
|
<span style={"width: #{capbar_width(pool["capacity_percent"])}%"}></span>
|
|
</div>
|
|
|
|
<div class="sizes">
|
|
used {format_bytes(pool["allocated_bytes"] || 0)} ·
|
|
free {format_bytes(pool["free_bytes"] || 0)} ·
|
|
total {format_bytes(pool["size_bytes"] || 0)}
|
|
<span class="muted">({pool["capacity_percent"] || 0}%)</span>
|
|
</div>
|
|
|
|
<div class="details">
|
|
frag {pool["fragmentation_percent"] || 0}% ·
|
|
err {pool["error_count"] || 0} ·
|
|
vdevs {pool["vdev_count"] || 0} (deg {pool["degraded_vdev_count"] || 0}) ·
|
|
{pool_scrub_line(pool)}
|
|
</div>
|
|
|
|
<div :for={v <- degraded_vdevs(pool)} class="callout err" style="margin-top: 0.4rem;">
|
|
{v["name"]} {v["state"]} · r={v["read_errors"]} w={v["write_errors"]} cksum={v["checksum_errors"]}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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
|
|
|
|
defp vm_status(%{"status" => "running"}), do: "ok"
|
|
defp vm_status(%{"status" => "stopped"}), do: "offline"
|
|
defp vm_status(_), do: "warning"
|
|
|
|
defp pool_badge_style("ONLINE"), do: "color: var(--ok);"
|
|
defp pool_badge_style(_), do: "color: var(--crit);"
|
|
|
|
defp pool_layout(pool) do
|
|
case pool["pool_type"] do
|
|
nil -> "—"
|
|
"" -> "—"
|
|
t -> t
|
|
end
|
|
end
|
|
|
|
defp capbar_level(cap) when is_number(cap) and cap >= 90, do: "crit"
|
|
defp capbar_level(cap) when is_number(cap) and cap >= 80, do: "warn"
|
|
defp capbar_level(_), do: "ok"
|
|
|
|
defp capbar_width(cap) when is_number(cap), do: cap |> max(0) |> min(100)
|
|
defp capbar_width(_), do: 0
|
|
|
|
defp pool_scrub_line(%{"scan_state" => "SCANNING"} = pool) do
|
|
"#{scan_verb(pool)} scanning"
|
|
end
|
|
|
|
defp pool_scrub_line(%{"last_scrub_end" => end_time} = pool)
|
|
when is_binary(end_time) and end_time != "" do
|
|
prefix =
|
|
case pool["scan_state"] do
|
|
"FINISHED" -> "#{scan_verb(pool)} finished "
|
|
_ -> "#{scan_verb(pool)} "
|
|
end
|
|
|
|
prefix <> end_time
|
|
end
|
|
|
|
defp pool_scrub_line(_), do: "scrub never"
|
|
|
|
defp scan_verb(%{"scan_function" => "resilver"}), do: "resilver"
|
|
defp scan_verb(_), do: "scrub"
|
|
|
|
defp degraded_vdevs(pool) do
|
|
(pool["vdevs"] || [])
|
|
|> Enum.filter(fn v -> Map.get(v, "state") not in [nil, "ONLINE"] end)
|
|
end
|
|
|
|
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
|
|
l1 = get_in(p, ["host", "load1"]) || "—"
|
|
l5 = get_in(p, ["host", "load5"]) || "—"
|
|
l15 = get_in(p, ["host", "load15"]) || "—"
|
|
"#{l1} / #{l5} / #{l15}"
|
|
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
|