Addresses code review: differentiate pool_scrub_line/1 FINISHED clause with the word "finished", test the degraded-vdev callout via a second DEGRADED pool in the fixture, and replace the generic "scrub" match with an assertion on the full finished line.
281 lines
9.1 KiB
Elixir
281 lines
9.1 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: #{pool["capacity_percent"] || 0}%"}></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 pool_scrub_line(%{"scan_state" => "SCANNING"}), do: "scrub scanning"
|
|
|
|
defp pool_scrub_line(%{"scan_state" => "FINISHED", "last_scrub_end" => end_time})
|
|
when is_binary(end_time) and end_time != "",
|
|
do: "scrub finished #{end_time}"
|
|
|
|
defp pool_scrub_line(%{"last_scrub_end" => end_time}) when is_binary(end_time) and end_time != "",
|
|
do: "scrub #{end_time}"
|
|
|
|
defp pool_scrub_line(_), do: "scrub never"
|
|
|
|
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
|