refactor(ui): minimalistic utilitarian redesign across all views
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.
This commit is contained in:
parent
1b031ecdc3
commit
50676a7cb8
9 changed files with 649 additions and 364 deletions
|
|
@ -2,4 +2,311 @@
|
|||
@import "tailwindcss/components";
|
||||
@import "tailwindcss/utilities";
|
||||
|
||||
/* This file is for your main application CSS */
|
||||
/* -------------------------------------------------------------------------- *
|
||||
* Minimal utilitarian dashboard.
|
||||
* Dark background, system sans for UI, monospace for data.
|
||||
* Status conveyed via borders and small badges, no gradients or shadows.
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--bg: #0f1115;
|
||||
--panel: #171a21;
|
||||
--panel-2: #1d2029;
|
||||
--border: #2a2f3a;
|
||||
--border-strong: #3a414f;
|
||||
--fg: #e6e9ef;
|
||||
--fg-bright: #ffffff;
|
||||
--muted: #8a93a4;
|
||||
--dim: #6b7280;
|
||||
|
||||
--ok: #7dd3a0;
|
||||
--warn: #e8b764;
|
||||
--crit: #e56b6b;
|
||||
--offline: #6b7280;
|
||||
|
||||
--link: #8fb4d9;
|
||||
--accent: var(--ok);
|
||||
|
||||
--mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, sans-serif;
|
||||
}
|
||||
|
||||
html, body {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a { color: var(--link); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
code, pre, .mono { font-family: var(--mono); }
|
||||
|
||||
h1, h2, h3 { font-weight: 600; color: var(--fg-bright); margin: 0; }
|
||||
h1 { font-size: 1.25rem; letter-spacing: -0.01em; }
|
||||
h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted); font-weight: 600; }
|
||||
h3 { font-size: 0.95rem; }
|
||||
|
||||
hr { border: 0; border-top: 1px solid var(--border); margin: 0; }
|
||||
|
||||
/* --- nav bar -------------------------------------------------------------- */
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.7rem 1.2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.nav .brand {
|
||||
color: var(--fg-bright);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.nav .links { display: flex; gap: 1.1rem; align-items: center; }
|
||||
.nav .links a { color: var(--muted); }
|
||||
.nav .links a.active, .nav .links a:hover { color: var(--fg-bright); text-decoration: none; }
|
||||
.nav .spacer { flex: 1; }
|
||||
.nav .signout { color: var(--muted); font-size: 0.85rem; }
|
||||
|
||||
/* --- page container ------------------------------------------------------- */
|
||||
.page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
.page .pagehead {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.page .pagehead .sub {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
/* --- panel ---------------------------------------------------------------- */
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.panel > header {
|
||||
padding: 0.55rem 0.9rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.panel > .body {
|
||||
padding: 0.9rem;
|
||||
}
|
||||
.panel > .body.tight { padding: 0; }
|
||||
|
||||
/* --- table ---------------------------------------------------------------- */
|
||||
.tbl {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.tbl th, .tbl td {
|
||||
text-align: left;
|
||||
padding: 0.45rem 0.9rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.tbl th {
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.tbl td .mono, .tbl td.mono { font-family: var(--mono); color: var(--fg-bright); }
|
||||
.tbl tr:last-child td { border-bottom: none; }
|
||||
.tbl tr:hover td { background: var(--panel-2); }
|
||||
.tbl td.right { text-align: right; }
|
||||
.tbl td.num { font-variant-numeric: tabular-nums; font-family: var(--mono); }
|
||||
|
||||
/* --- buttons / inputs ----------------------------------------------------- */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.35rem 0.9rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--panel-2);
|
||||
color: var(--fg);
|
||||
font-family: var(--sans);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.btn:hover { background: var(--border); color: var(--fg-bright); }
|
||||
.btn.primary { border-color: var(--ok); color: var(--ok); }
|
||||
.btn.primary:hover { background: rgba(125, 211, 160, 0.12); color: var(--ok); }
|
||||
.btn.danger { border-color: var(--crit); color: var(--crit); }
|
||||
.btn.danger:hover { background: rgba(229, 107, 107, 0.1); color: var(--crit); }
|
||||
.btn.ghost { border-color: transparent; background: transparent; color: var(--muted); }
|
||||
.btn.ghost:hover { color: var(--fg); background: var(--panel-2); }
|
||||
.btn.sm { padding: 0.15rem 0.5rem; font-size: 0.75rem; }
|
||||
|
||||
.input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.7rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--fg);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.9rem;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
.input:focus { border-color: var(--link); }
|
||||
.input::placeholder { color: var(--dim); }
|
||||
|
||||
/* --- form rows ------------------------------------------------------------ */
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
.form-row .input { flex: 1; }
|
||||
|
||||
/* --- status badges / ampel ----------------------------------------------- */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 2px;
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
.badge[data-status="ok"], .status-ok { color: var(--ok); }
|
||||
.badge[data-status="warning"], .status-warn { color: var(--warn); }
|
||||
.badge[data-status="critical"], .status-crit { color: var(--crit); }
|
||||
.badge[data-status="offline"], .badge[data-status="never_connected"], .status-offline { color: var(--offline); }
|
||||
.badge[data-status="online"] { color: var(--ok); }
|
||||
|
||||
/* --- host cards (overview grid) ----------------------------------------- */
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--offline);
|
||||
border-radius: 2px;
|
||||
padding: 0.9rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.card:hover { background: var(--panel-2); text-decoration: none; }
|
||||
.card[data-status="ok"] { border-left-color: var(--ok); }
|
||||
.card[data-status="warning"] { border-left-color: var(--warn); }
|
||||
.card[data-status="critical"] { border-left-color: var(--crit); }
|
||||
.card[data-status="offline"] { border-left-color: var(--offline); opacity: 0.7; }
|
||||
.card .name { font-family: var(--mono); font-weight: 600; color: var(--fg-bright); font-size: 1rem; }
|
||||
.card .seen { color: var(--muted); font-size: 0.75rem; }
|
||||
.card .stat { display: flex; justify-content: space-between; font-size: 0.8rem; }
|
||||
.card .stat .k { color: var(--muted); }
|
||||
.card .stat .v { font-family: var(--mono); color: var(--fg); }
|
||||
|
||||
/* --- detail rows --------------------------------------------------------- */
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: 0.5rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.kv dt { color: var(--muted); }
|
||||
.kv dd { margin: 0; font-family: var(--mono); color: var(--fg-bright); }
|
||||
|
||||
.pool-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.45rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
gap: 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.pool-row:last-child { border-bottom: none; }
|
||||
.pool-row .details { color: var(--muted); font-family: var(--mono); font-size: 0.78rem; }
|
||||
|
||||
/* --- callouts ------------------------------------------------------------ */
|
||||
.callout {
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--warn);
|
||||
padding: 0.5rem 0.8rem;
|
||||
background: var(--panel-2);
|
||||
font-size: 0.85rem;
|
||||
color: var(--warn);
|
||||
font-family: var(--mono);
|
||||
word-break: break-all;
|
||||
}
|
||||
.callout.err { border-left-color: var(--crit); color: var(--crit); }
|
||||
.callout.ok { border-left-color: var(--ok); color: var(--ok); }
|
||||
|
||||
/* --- utilities ----------------------------------------------------------- */
|
||||
.muted { color: var(--muted); }
|
||||
.dim { color: var(--dim); }
|
||||
.stack { display: flex; flex-direction: column; gap: 0.9rem; }
|
||||
.stack.sm { gap: 0.45rem; }
|
||||
.row { display: flex; gap: 0.6rem; align-items: center; }
|
||||
.row.wrap { flex-wrap: wrap; }
|
||||
.right { margin-left: auto; }
|
||||
.center { text-align: center; }
|
||||
.empty {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Login --------------------------------------------------------------------*/
|
||||
.login {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
.login .panel { width: 100%; max-width: 22rem; }
|
||||
.login .panel .body { padding: 1.2rem; display: flex; flex-direction: column; gap: 0.8rem; }
|
||||
.login .brand { color: var(--fg-bright); font-weight: 600; letter-spacing: 0.03em; font-size: 1rem; }
|
||||
|
||||
/* Flash --------------------------------------------------------------------*/
|
||||
.flash {
|
||||
padding: 0.5rem 1rem;
|
||||
border-left: 3px solid var(--warn);
|
||||
background: var(--panel-2);
|
||||
color: var(--warn);
|
||||
font-size: 0.85rem;
|
||||
margin: 0 1.2rem;
|
||||
}
|
||||
.flash.err { border-left-color: var(--crit); color: var(--crit); }
|
||||
.flash.info { border-left-color: var(--link); color: var(--link); }
|
||||
|
|
|
|||
26
server/lib/server_web/components/dashboard_nav.ex
Normal file
26
server/lib/server_web/components/dashboard_nav.ex
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
defmodule ServerWeb.DashboardNav do
|
||||
@moduledoc "Top navigation bar rendered inside authenticated LiveViews."
|
||||
|
||||
use Phoenix.Component
|
||||
use ServerWeb, :verified_routes
|
||||
|
||||
attr :active, :atom, required: true, values: [:overview, :vms, :admin, :other]
|
||||
|
||||
def nav(assigns) do
|
||||
~H"""
|
||||
<nav class="nav">
|
||||
<span class="brand">proxmox-monitor</span>
|
||||
<span class="links">
|
||||
<.link navigate={~p"/"} class={link_cls(@active, :overview)}>overview</.link>
|
||||
<.link navigate={~p"/vms"} class={link_cls(@active, :vms)}>vms</.link>
|
||||
<.link navigate={~p"/admin/hosts"} class={link_cls(@active, :admin)}>admin</.link>
|
||||
</span>
|
||||
<span class="spacer"></span>
|
||||
<.link href={~p"/logout"} method="delete" class="signout">sign out</.link>
|
||||
</nav>
|
||||
"""
|
||||
end
|
||||
|
||||
defp link_cls(active, key) when active == key, do: "active"
|
||||
defp link_cls(_, _), do: nil
|
||||
end
|
||||
|
|
@ -1,32 +1,7 @@
|
|||
<header class="px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between border-b border-zinc-100 py-3 text-sm">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/">
|
||||
<img src={~p"/images/logo.svg"} width="36" />
|
||||
</a>
|
||||
<p class="bg-brand/5 text-brand rounded-full px-2 font-medium leading-6">
|
||||
v<%= Application.spec(:phoenix, :vsn) %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 font-semibold leading-6 text-zinc-900">
|
||||
<a href="https://twitter.com/elixirphoenix" class="hover:text-zinc-700">
|
||||
@elixirphoenix
|
||||
</a>
|
||||
<a href="https://github.com/phoenixframework/phoenix" class="hover:text-zinc-700">
|
||||
GitHub
|
||||
</a>
|
||||
<a
|
||||
href="https://hexdocs.pm/phoenix/overview.html"
|
||||
class="rounded-lg bg-zinc-100 px-2 py-1 hover:bg-zinc-200/80"
|
||||
>
|
||||
Get Started <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="px-4 py-20 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<.flash_group flash={@flash} />
|
||||
<%= @inner_content %>
|
||||
</div>
|
||||
</main>
|
||||
<%= if info = Phoenix.Flash.get(@flash, :info) do %>
|
||||
<div class="flash info">{info}</div>
|
||||
<% end %>
|
||||
<%= if err = Phoenix.Flash.get(@flash, :error) do %>
|
||||
<div class="flash err">{err}</div>
|
||||
<% end %>
|
||||
{@inner_content}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="[scrollbar-gutter:stable]">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="csrf-token" content={get_csrf_token()} />
|
||||
<.live_title suffix=" · Phoenix Framework">
|
||||
<%= assigns[:page_title] || "Server" %>
|
||||
<.live_title suffix=" · Proxmox Monitor">
|
||||
{assigns[:page_title] || "Dashboard"}
|
||||
</.live_title>
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
|
||||
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-white">
|
||||
<%= @inner_content %>
|
||||
<body>
|
||||
{@inner_content}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -7,36 +7,30 @@
|
|||
<title>Sign in · Proxmox Monitor</title>
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
|
||||
</head>
|
||||
<body class="bg-white">
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<div class="max-w-sm w-full space-y-6 p-6 border border-zinc-200 rounded-lg shadow">
|
||||
<h1 class="text-xl font-semibold text-zinc-800">Proxmox Monitor</h1>
|
||||
|
||||
<%= if @error do %>
|
||||
<p class="text-sm text-red-600">{@error}</p>
|
||||
<% end %>
|
||||
|
||||
<form method="post" action="/login" class="space-y-4">
|
||||
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm text-zinc-700">Password</span>
|
||||
<body>
|
||||
<div class="login">
|
||||
<div class="panel">
|
||||
<header><span>proxmox-monitor</span><span class="mono dim">sign in</span></header>
|
||||
<div class="body">
|
||||
<%= if @error do %>
|
||||
<div class="callout err">{@error}</div>
|
||||
<% end %>
|
||||
<form method="post" action="/login" class="stack sm">
|
||||
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autofocus
|
||||
class="mt-1 block w-full rounded-md border-zinc-300 focus:border-zinc-400 focus:ring-0"
|
||||
autocomplete="current-password"
|
||||
placeholder="password"
|
||||
class="input"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-md bg-zinc-900 text-white py-2 hover:bg-zinc-700"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
<button type="submit" class="btn primary" style="justify-content: center;">
|
||||
sign in
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -7,9 +7,13 @@ defmodule ServerWeb.AdminHostsLive do
|
|||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:hosts, Hosts.list_all())
|
||||
|> assign(:new_token, nil)
|
||||
|> assign(:error, nil)}
|
||||
|> assign(
|
||||
hosts: Hosts.list_all(),
|
||||
form: to_form(%{"name" => ""}, as: :host),
|
||||
new_token: nil,
|
||||
error: nil,
|
||||
page_title: "Admin"
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -18,9 +22,12 @@ defmodule ServerWeb.AdminHostsLive do
|
|||
{:ok, {host, token}} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:hosts, Hosts.list_all())
|
||||
|> assign(:new_token, %{name: host.name, token: token})
|
||||
|> assign(:error, nil)}
|
||||
|> 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))}
|
||||
|
|
@ -33,8 +40,7 @@ defmodule ServerWeb.AdminHostsLive do
|
|||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:hosts, Hosts.list_all())
|
||||
|> assign(:new_token, %{name: host.name, token: token})}
|
||||
|> assign(hosts: Hosts.list_all(), new_token: %{name: host.name, token: token})}
|
||||
end
|
||||
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
|
|
@ -44,89 +50,93 @@ defmodule ServerWeb.AdminHostsLive do
|
|||
end
|
||||
|
||||
defp changeset_message(cs) do
|
||||
cs.errors
|
||||
|> Enum.map_join(", ", fn {k, {msg, _}} -> "#{k}: #{msg}" end)
|
||||
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>
|
||||
<ServerWeb.DashboardNav.nav active={:admin} />
|
||||
|
||||
<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 class="page">
|
||||
<div class="pagehead">
|
||||
<h1>Admin · Hosts</h1>
|
||||
<span class="sub">{length(@hosts)} registered</span>
|
||||
</div>
|
||||
|
||||
<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 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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<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 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
|
||||
defp format_seen(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M UTC")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ defmodule ServerWeb.HostDetailLive do
|
|||
if connected?(socket),
|
||||
do: Phoenix.PubSub.subscribe(Server.PubSub, "metrics:#{host.id}")
|
||||
|
||||
{:ok, socket |> assign(:host, host) |> load_samples()}
|
||||
{:ok, socket |> assign(host: host, page_title: host.name) |> load_samples()}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -38,141 +38,129 @@ defmodule ServerWeb.HostDetailLive do
|
|||
@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">
|
||||
<ServerWeb.DashboardNav.nav active={:overview} />
|
||||
|
||||
<div class="page">
|
||||
<div class="pagehead">
|
||||
<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"}
|
||||
<h1>{@host.name}</h1>
|
||||
<div class="sub">
|
||||
{sys_line(@slow)} · uptime {uptime(@fast)} · last seen {last_seen(@host.last_seen_at)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<span class="badge" data-status={@host.status}>{@host.status}</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div class="panel">
|
||||
<header><span>Host</span></header>
|
||||
<div class="body">
|
||||
<dl class="kv">
|
||||
<dt>load (1/5/15)</dt><dd>{host_load(@fast)}</dd>
|
||||
<dt>memory</dt><dd>{host_mem(@fast)}</dd>
|
||||
<dt>agent</dt><dd>{@host.agent_version || "—"}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div class="panel">
|
||||
<header><span>ZFS pools</span><span class="mono">{length(pools(@fast))}</span></header>
|
||||
<div class="body tight">
|
||||
<div :if={pools(@fast) == []} class="empty">No data.</div>
|
||||
<div :for={pool <- pools(@fast)} class="pool-row" style="padding: 0.6rem 0.9rem;">
|
||||
<div>
|
||||
<span class="mono" style="color: var(--fg-bright); font-weight: 600;">
|
||||
{pool["name"]}
|
||||
</span>
|
||||
<span class="badge" style={pool_badge_style(pool["health"])}>{pool["health"]}</span>
|
||||
<div class="details">
|
||||
cap {pool["capacity_percent"]}% ·
|
||||
frag {pool["fragmentation_percent"] || 0}% ·
|
||||
err {pool["error_count"] || 0} ·
|
||||
vdevs {pool["vdev_count"] || 0} (deg {pool["degraded_vdev_count"] || 0})
|
||||
</div>
|
||||
</div>
|
||||
<div class="mono muted" style="font-size: 0.75rem; align-self: center; text-align: right;">
|
||||
scrub<br/>{pool["last_scrub_end"] || "never"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 class="panel">
|
||||
<header><span>Snapshots</span><span class="mono">{length(datasets(@medium))}</span></header>
|
||||
<div class="body tight">
|
||||
<table class="tbl" :if={datasets(@medium) != []}>
|
||||
<thead>
|
||||
<tr><th>Dataset</th><th>Count</th><th>Oldest</th><th>Newest</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :for={ds <- datasets(@medium)}>
|
||||
<td class="mono">{ds["name"]}</td>
|
||||
<td class="num">{ds["snapshot_count"]}</td>
|
||||
<td class="mono">{unix_to_date(ds["oldest_snapshot_unix"])}</td>
|
||||
<td class="mono">{unix_to_date(ds["newest_snapshot_unix"])}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div :if={datasets(@medium) == []} class="empty">No data.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<header><span>Storage</span><span class="mono">{length(storages(@fast))}</span></header>
|
||||
<div class="body tight">
|
||||
<table class="tbl" :if={storages(@fast) != []}>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Type</th><th>Usage</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :for={s <- storages(@fast)}>
|
||||
<td class="mono">{s["name"]}</td>
|
||||
<td>{s["type"]}</td>
|
||||
<td class="mono">{storage_usage(s)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div :if={storages(@fast) == []} class="empty">No data.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<header><span>VMs / LXCs</span><span class="mono">{length(vms(@fast))}</span></header>
|
||||
<div class="body tight">
|
||||
<table class="tbl" :if={vms(@fast) != []}>
|
||||
<thead>
|
||||
<tr><th>VMID</th><th>Name</th><th>Type</th><th>Status</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :for={vm <- vms(@fast)}>
|
||||
<td class="num">{vm["vmid"]}</td>
|
||||
<td class="mono">{vm["name"]}</td>
|
||||
<td>{vm["type"]}</td>
|
||||
<td><span class="badge" data-status={vm_status(vm)}>{vm["status"]}</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div :if={vms(@fast) == []} class="empty">No data.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :label, :string, required: true
|
||||
attr :value, :string, required: true
|
||||
defp vm_status(%{"status" => "running"}), do: "ok"
|
||||
defp vm_status(%{"status" => "stopped"}), do: "offline"
|
||||
defp vm_status(_), do: "warning"
|
||||
|
||||
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 pool_badge_style("ONLINE"), do: "color: var(--ok);"
|
||||
defp pool_badge_style(_), do: "color: var(--crit);"
|
||||
|
||||
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 -> "—"
|
||||
|
|
@ -182,10 +170,8 @@ defmodule ServerWeb.HostDetailLive do
|
|||
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"
|
||||
|
|
@ -194,7 +180,6 @@ defmodule ServerWeb.HostDetailLive do
|
|||
end
|
||||
|
||||
defp host_load(nil), do: "—"
|
||||
|
||||
defp host_load(%{payload: p}) do
|
||||
l1 = get_in(p, ["host", "load1"]) || "—"
|
||||
l5 = get_in(p, ["host", "load5"]) || "—"
|
||||
|
|
@ -203,7 +188,6 @@ defmodule ServerWeb.HostDetailLive do
|
|||
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"])
|
||||
|
|
@ -211,9 +195,7 @@ defmodule ServerWeb.HostDetailLive do
|
|||
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
|
||||
|
||||
|
|
@ -237,7 +219,6 @@ defmodule ServerWeb.HostDetailLive do
|
|||
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")
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ defmodule ServerWeb.OverviewLive do
|
|||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
if connected?(socket), do: Phoenix.PubSub.subscribe(Server.PubSub, "metrics")
|
||||
{:ok, assign(socket, :hosts, load_hosts())}
|
||||
{:ok, assign(socket, hosts: load_hosts(), page_title: "Overview")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -25,70 +25,59 @@ defmodule ServerWeb.OverviewLive do
|
|||
@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 class="space-x-4">
|
||||
<h1 class="text-2xl font-bold inline">Proxmox Monitor</h1>
|
||||
<.link navigate={~p"/vms"} class="text-sm text-zinc-600 hover:text-zinc-900">VMs</.link>
|
||||
<.link navigate={~p"/admin/hosts"} class="text-sm text-zinc-600 hover:text-zinc-900">
|
||||
Admin
|
||||
</.link>
|
||||
</div>
|
||||
<.link href={~p"/logout"} method="delete" class="text-sm text-zinc-500">Sign out</.link>
|
||||
</header>
|
||||
<ServerWeb.DashboardNav.nav active={:overview} />
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div
|
||||
:for={entry <- @hosts}
|
||||
data-role="host-card"
|
||||
data-status={Atom.to_string(entry.status)}
|
||||
class={"p-4 rounded-lg border-l-4 bg-white shadow-sm " <> border_class(entry.status)}
|
||||
>
|
||||
<.link navigate={~p"/hosts/#{entry.host.name}"} class="block space-y-2">
|
||||
<div class="flex justify-between items-baseline">
|
||||
<span class="font-semibold text-zinc-900">{entry.host.name}</span>
|
||||
<span class={"text-xs uppercase tracking-wide " <> text_class(entry.status)}>
|
||||
{entry.status}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500">
|
||||
Last seen: {last_seen(entry.host.last_seen_at)}
|
||||
</div>
|
||||
<div :if={entry.sample} class="text-sm text-zinc-700 space-y-1">
|
||||
<div>Load: {format_load(entry.sample.payload)}</div>
|
||||
<div>RAM used: {format_mem(entry.sample.payload)}</div>
|
||||
<div>Pools: {pool_summary(entry.sample.payload)}</div>
|
||||
<div>VMs: {vm_count(entry.sample.payload)}</div>
|
||||
</div>
|
||||
<div :if={is_nil(entry.sample)} class="text-sm text-zinc-400 italic">
|
||||
No samples yet
|
||||
</div>
|
||||
</.link>
|
||||
<div class="page">
|
||||
<div class="pagehead">
|
||||
<h1>Hosts</h1>
|
||||
<span class="sub">{summary_line(@hosts)}</span>
|
||||
</div>
|
||||
|
||||
<div :if={@hosts == []} class="panel">
|
||||
<div class="empty">
|
||||
No hosts registered. Add one at
|
||||
<.link navigate={~p"/admin/hosts"}>/admin/hosts</.link>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p :if={@hosts == []} class="text-zinc-500">
|
||||
No hosts registered yet. Add one via <code>/admin/hosts</code>.
|
||||
</p>
|
||||
<div class="cards">
|
||||
<.link :for={entry <- @hosts} navigate={~p"/hosts/#{entry.host.name}"} class="card"
|
||||
data-status={Atom.to_string(entry.status)} data-role="host-card">
|
||||
<div class="row">
|
||||
<span class="name">{entry.host.name}</span>
|
||||
<span class="right badge" data-status={Atom.to_string(entry.status)}>
|
||||
{entry.status}
|
||||
</span>
|
||||
</div>
|
||||
<div class="seen">last seen {last_seen(entry.host.last_seen_at)}</div>
|
||||
<%= if entry.sample do %>
|
||||
<div class="stat"><span class="k">load</span><span class="v">{load(entry.sample.payload)}</span></div>
|
||||
<div class="stat"><span class="k">ram</span><span class="v">{mem(entry.sample.payload)}</span></div>
|
||||
<div class="stat"><span class="k">pools</span><span class="v">{pool_summary(entry.sample.payload)}</span></div>
|
||||
<div class="stat"><span class="k">vms</span><span class="v">{vm_count(entry.sample.payload)}</span></div>
|
||||
<% else %>
|
||||
<div class="dim">no samples yet</div>
|
||||
<% end %>
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp border_class(:ok), do: "border-green-500"
|
||||
defp border_class(:warning), do: "border-yellow-500"
|
||||
defp border_class(:critical), do: "border-red-500"
|
||||
defp border_class(:offline), do: "border-zinc-400"
|
||||
|
||||
defp text_class(:ok), do: "text-green-600"
|
||||
defp text_class(:warning), do: "text-yellow-600"
|
||||
defp text_class(:critical), do: "text-red-600"
|
||||
defp text_class(:offline), do: "text-zinc-500"
|
||||
defp summary_line([]), do: "0 hosts"
|
||||
defp summary_line(hosts) do
|
||||
n = length(hosts)
|
||||
by = Enum.frequencies_by(hosts, & &1.status)
|
||||
parts =
|
||||
[:ok, :warning, :critical, :offline]
|
||||
|> Enum.filter(&Map.has_key?(by, &1))
|
||||
|> Enum.map(fn s -> "#{by[s]} #{s}" end)
|
||||
"#{n} host#{if n == 1, do: "", else: "s"} · " <> Enum.join(parts, " · ")
|
||||
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"
|
||||
|
|
@ -96,23 +85,20 @@ defmodule ServerWeb.OverviewLive do
|
|||
end
|
||||
end
|
||||
|
||||
defp format_load(payload) do
|
||||
defp load(payload) do
|
||||
case get_in(payload, ["host", "load1"]) do
|
||||
nil -> "—"
|
||||
l -> :io_lib.format("~.2f", [l]) |> to_string()
|
||||
end
|
||||
end
|
||||
|
||||
defp format_mem(payload) do
|
||||
defp mem(payload) do
|
||||
used = get_in(payload, ["host", "mem_used_bytes"])
|
||||
total = get_in(payload, ["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)}%"
|
||||
|
||||
_ ->
|
||||
"—"
|
||||
_ -> "—"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -120,11 +106,11 @@ defmodule ServerWeb.OverviewLive do
|
|||
pools = get_in(payload, ["zfs_pools", "pools"]) || []
|
||||
total = length(pools)
|
||||
bad = Enum.count(pools, &(&1["health"] != "ONLINE"))
|
||||
if total == 0, do: "—", else: "#{total - bad}/#{total} ok"
|
||||
if total == 0, do: "—", else: "#{total - bad}/#{total}"
|
||||
end
|
||||
|
||||
defp vm_count(payload) do
|
||||
vms = get_in(payload, ["vms_runtime", "vms"]) || []
|
||||
length(vms)
|
||||
length(vms) |> to_string()
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ defmodule ServerWeb.VmSearchLive do
|
|||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
if connected?(socket), do: Phoenix.PubSub.subscribe(Server.PubSub, "metrics")
|
||||
{:ok, socket |> assign(:q, "") |> assign(:vms, load_vms())}
|
||||
{:ok, socket |> assign(q: "", vms: load_vms(), page_title: "VM Search")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -54,51 +54,57 @@ defmodule ServerWeb.VmSearchLive do
|
|||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="p-6 max-w-6xl mx-auto space-y-4">
|
||||
<.link navigate={~p"/"} class="text-sm text-zinc-500 hover:text-zinc-900">← Back</.link>
|
||||
<h1 class="text-2xl font-bold">VM Search</h1>
|
||||
<ServerWeb.DashboardNav.nav active={:vms} />
|
||||
|
||||
<div class="page">
|
||||
<div class="pagehead">
|
||||
<h1>VM Search</h1>
|
||||
<span class="sub">{length(filter(@vms, @q))} / {length(@vms)} shown</span>
|
||||
</div>
|
||||
|
||||
<form phx-change="search">
|
||||
<input
|
||||
name="q"
|
||||
value={@q}
|
||||
placeholder="Search by name or IP…"
|
||||
placeholder="name or ip…"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
class="w-full rounded-md border-zinc-300 focus:border-zinc-400 focus:ring-0"
|
||||
class="input"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<table class="w-full text-sm bg-white border rounded-lg">
|
||||
<thead>
|
||||
<tr class="text-left text-zinc-500 border-b">
|
||||
<th class="py-2 px-3">Name</th>
|
||||
<th class="py-2 px-3">Host</th>
|
||||
<th class="py-2 px-3">Type</th>
|
||||
<th class="py-2 px-3">Status</th>
|
||||
<th class="py-2 px-3">IPs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :for={vm <- filter(@vms, @q)} class="border-b last:border-b-0">
|
||||
<td class="py-2 px-3 font-mono">{vm.name}</td>
|
||||
<td class="py-2 px-3">
|
||||
<.link
|
||||
navigate={~p"/hosts/#{vm.host_name}"}
|
||||
class="text-zinc-700 hover:text-zinc-900 underline"
|
||||
>
|
||||
{vm.host_name}
|
||||
</.link>
|
||||
</td>
|
||||
<td class="py-2 px-3">{vm.type}</td>
|
||||
<td class="py-2 px-3">{vm.status}</td>
|
||||
<td class="py-2 px-3 font-mono text-xs">{Enum.join(vm.ips, ", ")}</td>
|
||||
</tr>
|
||||
<tr :if={filter(@vms, @q) == []}>
|
||||
<td colspan="5" class="py-4 px-3 text-center text-zinc-500">No matches.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="panel">
|
||||
<div class="body tight">
|
||||
<table class="tbl" :if={filter(@vms, @q) != []}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>VMID</th>
|
||||
<th>Name</th>
|
||||
<th>Host</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>IPs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :for={vm <- filter(@vms, @q)}>
|
||||
<td class="num">{vm.vmid}</td>
|
||||
<td class="mono">{vm.name}</td>
|
||||
<td><.link navigate={~p"/hosts/#{vm.host_name}"}>{vm.host_name}</.link></td>
|
||||
<td>{vm.type}</td>
|
||||
<td><span class="badge" data-status={vm_status(vm)}>{vm.status}</span></td>
|
||||
<td class="mono" style="font-size: 0.75rem;">{Enum.join(vm.ips, ", ")}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div :if={filter(@vms, @q) == []} class="empty">No matches.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp vm_status(%{status: "running"}), do: "ok"
|
||||
defp vm_status(%{status: "stopped"}), do: "offline"
|
||||
defp vm_status(_), do: "warning"
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue