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} +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
NameStatusAgentLast seenActions
{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