feat(server): host detail LiveView with metrics/pools/snapshots/storage/vms
This commit is contained in:
parent
d0507f290e
commit
d65832964e
2 changed files with 342 additions and 0 deletions
258
server/lib/server_web/live/host_detail_live.ex
Normal file
258
server/lib/server_web/live/host_detail_live.ex
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
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) |> 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"""
|
||||||
|
<div class="p-6 max-w-6xl mx-auto space-y-6">
|
||||||
|
<header class="flex justify-between items-center">
|
||||||
|
<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"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
attr :label, :string, required: true
|
||||||
|
attr :value, :string, required: true
|
||||||
|
|
||||||
|
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 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
|
||||||
84
server/test/server_web/live/host_detail_live_test.exs
Normal file
84
server/test/server_web/live/host_detail_live_test.exs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
defmodule ServerWeb.HostDetailLiveTest do
|
||||||
|
use ServerWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
alias Server.{Hosts, Metrics}
|
||||||
|
|
||||||
|
defp auth(conn), do: Plug.Test.init_test_session(conn, %{authenticated: true})
|
||||||
|
|
||||||
|
setup do
|
||||||
|
{:ok, {host, _}} = Hosts.create_host("pve-01")
|
||||||
|
{:ok, _} = Hosts.mark_online(host, "0.1.0")
|
||||||
|
|
||||||
|
fast = %{
|
||||||
|
"host" => %{"load1" => 0.25, "load5" => 0.3, "load15" => 0.4},
|
||||||
|
"zfs_pools" => %{
|
||||||
|
"pools" => [
|
||||||
|
%{
|
||||||
|
"name" => "rpool",
|
||||||
|
"health" => "ONLINE",
|
||||||
|
"capacity_percent" => 40,
|
||||||
|
"error_count" => 0,
|
||||||
|
"last_scrub_end" => "Sat Apr 19 02:00:00 2026"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"storage" => %{
|
||||||
|
"storages" => [
|
||||||
|
%{"name" => "local", "type" => "dir", "used_bytes" => 10, "total_bytes" => 100}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"vms_runtime" => %{
|
||||||
|
"vms" => [%{"vmid" => 100, "name" => "nginx", "type" => "qemu", "status" => "running"}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
medium = %{
|
||||||
|
"zfs_datasets" => %{
|
||||||
|
"datasets" => [
|
||||||
|
%{
|
||||||
|
"name" => "rpool/data",
|
||||||
|
"snapshot_count" => 2,
|
||||||
|
"newest_snapshot_unix" => 1_745_193_600,
|
||||||
|
"oldest_snapshot_unix" => 1_745_107_200
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"vms_detail" => %{"vms" => []}
|
||||||
|
}
|
||||||
|
|
||||||
|
slow = %{
|
||||||
|
"system_info" => %{
|
||||||
|
"pve_version" => "pve-manager/8.3.1",
|
||||||
|
"zfs_version" => "zfs-2.3.0",
|
||||||
|
"pending_updates" => 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, _} = Metrics.record_sample(host.id, "fast", DateTime.utc_now(), fast)
|
||||||
|
{:ok, _} = Metrics.record_sample(host.id, "medium", DateTime.utc_now(), medium)
|
||||||
|
{:ok, _} = Metrics.record_sample(host.id, "slow", DateTime.utc_now(), slow)
|
||||||
|
|
||||||
|
%{host: host}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders sections for metrics, pools, snapshots, storage, VMs", %{
|
||||||
|
conn: conn,
|
||||||
|
host: host
|
||||||
|
} do
|
||||||
|
{:ok, _view, html} = live(auth(conn), ~p"/hosts/#{host.name}")
|
||||||
|
|
||||||
|
assert html =~ "pve-01"
|
||||||
|
assert html =~ "pve-manager/8.3.1"
|
||||||
|
assert html =~ "rpool"
|
||||||
|
assert html =~ "ONLINE"
|
||||||
|
assert html =~ "nginx"
|
||||||
|
assert html =~ "rpool/data"
|
||||||
|
assert html =~ "local"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "404 for unknown host", %{conn: conn} do
|
||||||
|
assert {:error, {:live_redirect, %{to: "/"}}} =
|
||||||
|
live(auth(conn), ~p"/hosts/unknown")
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Add a link
Reference in a new issue