diff --git a/server/lib/server_web/live/admin_hosts_live.ex b/server/lib/server_web/live/admin_hosts_live.ex
new file mode 100644
index 0000000..538b1d3
--- /dev/null
+++ b/server/lib/server_web/live/admin_hosts_live.ex
@@ -0,0 +1,132 @@
+defmodule ServerWeb.AdminHostsLive do
+ use ServerWeb, :live_view
+
+ alias Server.{Hosts, Repo, Schema.Host}
+
+ @impl true
+ def mount(_params, _session, socket) do
+ {:ok,
+ socket
+ |> assign(:hosts, Hosts.list_all())
+ |> assign(:new_token, nil)
+ |> assign(:error, nil)}
+ end
+
+ @impl true
+ def handle_event("create", %{"host" => %{"name" => name}}, socket) do
+ case Hosts.create_host(name) do
+ {:ok, {host, token}} ->
+ {:noreply,
+ socket
+ |> assign(:hosts, Hosts.list_all())
+ |> assign(:new_token, %{name: host.name, token: token})
+ |> assign(:error, nil)}
+
+ {:error, cs} ->
+ {:noreply, assign(socket, :error, changeset_message(cs))}
+ end
+ end
+
+ def handle_event("rotate", %{"id" => id}, socket) do
+ %Host{} = host = Repo.get!(Host, id)
+ {:ok, {_, token}} = Hosts.rotate_token(host)
+
+ {:noreply,
+ socket
+ |> assign(:hosts, Hosts.list_all())
+ |> assign(:new_token, %{name: host.name, token: token})}
+ end
+
+ def handle_event("delete", %{"id" => id}, socket) do
+ %Host{} = host = Repo.get!(Host, id)
+ {:ok, _} = Hosts.delete_host(host)
+ {:noreply, assign(socket, :hosts, Hosts.list_all())}
+ end
+
+ defp changeset_message(cs) do
+ cs.errors
+ |> Enum.map_join(", ", fn {k, {msg, _}} -> "#{k}: #{msg}" end)
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ <.link navigate={~p"/"} class="text-sm text-zinc-500 hover:text-zinc-900">← Back
+
Hosts
+
+
+ Register a new host
+
+ {@error}
+
+
+
+ Token for {@new_token.name} (shown once):
+
+
{@new_token.token}
+
+
+
+
+
+
+
+ | Name |
+ Status |
+ Agent |
+ Last seen |
+ Actions |
+
+
+
+
+ | {h.name} |
+ {h.status} |
+ {h.agent_version || "—"} |
+ {format_seen(h.last_seen_at)} |
+
+
+
+ |
+
+
+ |
+ No hosts yet.
+ |
+
+
+
+
+
+ """
+ end
+
+ defp format_seen(nil), do: "never"
+
+ defp format_seen(%DateTime{} = dt) do
+ Calendar.strftime(dt, "%Y-%m-%d %H:%M UTC")
+ end
+end
diff --git a/server/test/server_web/live/admin_hosts_live_test.exs b/server/test/server_web/live/admin_hosts_live_test.exs
new file mode 100644
index 0000000..8e61afc
--- /dev/null
+++ b/server/test/server_web/live/admin_hosts_live_test.exs
@@ -0,0 +1,46 @@
+defmodule ServerWeb.AdminHostsLiveTest do
+ use ServerWeb.ConnCase, async: false
+
+ import Phoenix.LiveViewTest
+ alias Server.Hosts
+
+ defp auth(conn), do: Plug.Test.init_test_session(conn, %{authenticated: true})
+
+ test "lists hosts", %{conn: conn} do
+ {:ok, {_, _}} = Hosts.create_host("pve-01")
+ {:ok, _view, html} = live(auth(conn), "/admin/hosts")
+ assert html =~ "pve-01"
+ end
+
+ test "creates a new host and reveals the token", %{conn: conn} do
+ {:ok, view, _html} = live(auth(conn), "/admin/hosts")
+
+ html =
+ view
+ |> form("form[phx-submit=create]", host: %{name: "pve-new"})
+ |> render_submit()
+
+ assert html =~ "pve-new"
+ assert html =~ ~r/[A-Za-z0-9_\-]{40,}/
+ end
+
+ test "revokes token", %{conn: conn} do
+ {:ok, {host, _}} = Hosts.create_host("pve-01")
+ original_hash = host.token_hash
+
+ {:ok, view, _html} = live(auth(conn), "/admin/hosts")
+
+ _html = render_click(view, "rotate", %{"id" => to_string(host.id)})
+
+ reloaded = Server.Repo.reload!(host)
+ refute reloaded.token_hash == original_hash
+ end
+
+ test "deletes a host", %{conn: conn} do
+ {:ok, {host, _}} = Hosts.create_host("pve-gone")
+ {:ok, view, _html} = live(auth(conn), "/admin/hosts")
+
+ html = render_click(view, "delete", %{"id" => to_string(host.id)})
+ refute html =~ "pve-gone"
+ end
+end