New design language:
- dark background, system sans for UI, monospace for data
- single green accent, amber/red for warn/critical
- square-bordered panels + tables, no rounded cards or shadows
- status conveyed via left-border on overview cards + badges
Changes:
- new app.css defines CSS vars + component classes (.panel, .tbl,
.card, .btn, .input, .badge with [data-status=*])
- new ServerWeb.DashboardNav function component for a shared top nav
with active-link highlighting; replaces per-view navigation clutter
- strip the Phoenix welcome scaffold (logo, version badge, twitter/GH
links) from layouts/app.html.heex; leaves only flash + content
- root.html.heex title suffix switched to 'Proxmox Monitor', body
loses the Tailwind-white background
- rewrite render/1 in all four LiveViews + login template to use the
new classes; admin form now uses <.form for={@form}> and properly
clears on success
- login page redesigned to a single tight panel matching the rest
All 58 tests still pass; 'mix compile --warnings-as-errors' is clean.
142 lines
4.4 KiB
Elixir
142 lines
4.4 KiB
Elixir
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(),
|
|
form: to_form(%{"name" => ""}, as: :host),
|
|
new_token: nil,
|
|
error: nil,
|
|
page_title: "Admin"
|
|
)}
|
|
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(),
|
|
form: to_form(%{"name" => ""}, as: :host),
|
|
new_token: %{name: host.name, token: token},
|
|
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(), 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"""
|
|
<ServerWeb.DashboardNav.nav active={:admin} />
|
|
|
|
<div class="page">
|
|
<div class="pagehead">
|
|
<h1>Admin · Hosts</h1>
|
|
<span class="sub">{length(@hosts)} registered</span>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<header><span>Register a host</span></header>
|
|
<div class="body">
|
|
<.form for={@form} phx-submit="create" class="form-row">
|
|
<input
|
|
name="host[name]"
|
|
value=""
|
|
placeholder="pve-hostname"
|
|
required
|
|
autocomplete="off"
|
|
class="input"
|
|
/>
|
|
<button type="submit" class="btn primary">Add</button>
|
|
</.form>
|
|
<p :if={@error} class="callout err" style="margin-top: 0.7rem;">{@error}</p>
|
|
<div :if={@new_token} class="callout ok" style="margin-top: 0.7rem;">
|
|
Token for <strong class="mono">{@new_token.name}</strong> — shown once:<br/>
|
|
<code class="mono" style="color: var(--fg-bright); user-select: all;">{@new_token.token}</code>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<header><span>All hosts</span></header>
|
|
<div class="body tight">
|
|
<table class="tbl" :if={@hosts != []}>
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Status</th>
|
|
<th>Agent</th>
|
|
<th>Last seen</th>
|
|
<th class="right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr :for={h <- @hosts}>
|
|
<td class="mono">
|
|
<.link navigate={~p"/hosts/#{h.name}"}>{h.name}</.link>
|
|
</td>
|
|
<td><span class="badge" data-status={h.status}>{h.status}</span></td>
|
|
<td class="mono">{h.agent_version || "—"}</td>
|
|
<td class="mono">{format_seen(h.last_seen_at)}</td>
|
|
<td class="right">
|
|
<button
|
|
type="button"
|
|
phx-click="rotate"
|
|
phx-value-id={h.id}
|
|
class="btn sm ghost"
|
|
data-confirm={"Rotate token for #{h.name}? Old token stops working immediately."}
|
|
>
|
|
rotate
|
|
</button>
|
|
<button
|
|
type="button"
|
|
phx-click="delete"
|
|
phx-value-id={h.id}
|
|
class="btn sm danger"
|
|
data-confirm={"Delete #{h.name} and all its metrics?"}
|
|
>
|
|
delete
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<div :if={@hosts == []} class="empty">No hosts yet.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp format_seen(nil), do: "never"
|
|
defp format_seen(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M UTC")
|
|
end
|