feat(server): admin LiveView for host registration, rotate, delete
This commit is contained in:
parent
94034eea9b
commit
667fd7160c
2 changed files with 178 additions and 0 deletions
132
server/lib/server_web/live/admin_hosts_live.ex
Normal file
132
server/lib/server_web/live/admin_hosts_live.ex
Normal file
|
|
@ -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"""
|
||||||
|
<div class="p-6 max-w-4xl mx-auto space-y-6">
|
||||||
|
<.link navigate={~p"/"} class="text-sm text-zinc-500 hover:text-zinc-900">← Back</.link>
|
||||||
|
<h1 class="text-2xl font-bold">Hosts</h1>
|
||||||
|
|
||||||
|
<section class="bg-white border rounded-lg p-4">
|
||||||
|
<h2 class="font-semibold mb-2">Register a new host</h2>
|
||||||
|
<form phx-submit="create" class="flex gap-2">
|
||||||
|
<input
|
||||||
|
name="host[name]"
|
||||||
|
placeholder="pve-hostname"
|
||||||
|
required
|
||||||
|
class="flex-1 rounded-md border-zinc-300 focus:border-zinc-400 focus:ring-0"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="rounded-md bg-zinc-900 text-white px-4">Add</button>
|
||||||
|
</form>
|
||||||
|
<p :if={@error} class="text-sm text-red-600 mt-2">{@error}</p>
|
||||||
|
|
||||||
|
<div :if={@new_token} class="mt-4 p-3 bg-amber-50 border border-amber-200 rounded">
|
||||||
|
<p class="text-sm font-semibold text-amber-900">
|
||||||
|
Token for {@new_token.name} (shown once):
|
||||||
|
</p>
|
||||||
|
<code class="block mt-1 break-all text-sm">{@new_token.token}</code>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="bg-white border rounded-lg">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-left text-zinc-500 border-b">
|
||||||
|
<th class="py-2 px-3">Name</th>
|
||||||
|
<th class="py-2 px-3">Status</th>
|
||||||
|
<th class="py-2 px-3">Agent</th>
|
||||||
|
<th class="py-2 px-3">Last seen</th>
|
||||||
|
<th class="py-2 px-3 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr :for={h <- @hosts} class="border-b last:border-b-0">
|
||||||
|
<td class="py-2 px-3 font-mono">{h.name}</td>
|
||||||
|
<td class="py-2 px-3">{h.status}</td>
|
||||||
|
<td class="py-2 px-3">{h.agent_version || "—"}</td>
|
||||||
|
<td class="py-2 px-3">{format_seen(h.last_seen_at)}</td>
|
||||||
|
<td class="py-2 px-3 text-right space-x-2">
|
||||||
|
<button
|
||||||
|
phx-click="rotate"
|
||||||
|
phx-value-id={h.id}
|
||||||
|
class="text-xs text-zinc-700 underline"
|
||||||
|
data-confirm={"Rotate token for #{h.name}? Old token will stop working."}
|
||||||
|
>
|
||||||
|
Rotate
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
phx-click="delete"
|
||||||
|
phx-value-id={h.id}
|
||||||
|
class="text-xs text-red-600 underline"
|
||||||
|
data-confirm={"Delete #{h.name} and all its metrics?"}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr :if={@hosts == []}>
|
||||||
|
<td colspan="5" class="py-4 px-3 text-center text-zinc-500">
|
||||||
|
No hosts yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_seen(nil), do: "never"
|
||||||
|
|
||||||
|
defp format_seen(%DateTime{} = dt) do
|
||||||
|
Calendar.strftime(dt, "%Y-%m-%d %H:%M UTC")
|
||||||
|
end
|
||||||
|
end
|
||||||
46
server/test/server_web/live/admin_hosts_live_test.exs
Normal file
46
server/test/server_web/live/admin_hosts_live_test.exs
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue