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/components";
|
||||||
@import "tailwindcss/utilities";
|
@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">
|
<%= if info = Phoenix.Flash.get(@flash, :info) do %>
|
||||||
<div class="flex items-center justify-between border-b border-zinc-100 py-3 text-sm">
|
<div class="flash info">{info}</div>
|
||||||
<div class="flex items-center gap-4">
|
<% end %>
|
||||||
<a href="/">
|
<%= if err = Phoenix.Flash.get(@flash, :error) do %>
|
||||||
<img src={~p"/images/logo.svg"} width="36" />
|
<div class="flash err">{err}</div>
|
||||||
</a>
|
<% end %>
|
||||||
<p class="bg-brand/5 text-brand rounded-full px-2 font-medium leading-6">
|
{@inner_content}
|
||||||
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>
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" class="[scrollbar-gutter:stable]">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="csrf-token" content={get_csrf_token()} />
|
<meta name="csrf-token" content={get_csrf_token()} />
|
||||||
<.live_title suffix=" · Phoenix Framework">
|
<.live_title suffix=" · Proxmox Monitor">
|
||||||
<%= assigns[:page_title] || "Server" %>
|
{assigns[:page_title] || "Dashboard"}
|
||||||
</.live_title>
|
</.live_title>
|
||||||
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
|
<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 defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-white">
|
<body>
|
||||||
<%= @inner_content %>
|
{@inner_content}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -7,36 +7,30 @@
|
||||||
<title>Sign in · Proxmox Monitor</title>
|
<title>Sign in · Proxmox Monitor</title>
|
||||||
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
|
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-white">
|
<body>
|
||||||
<div class="min-h-screen flex items-center justify-center">
|
<div class="login">
|
||||||
<div class="max-w-sm w-full space-y-6 p-6 border border-zinc-200 rounded-lg shadow">
|
<div class="panel">
|
||||||
<h1 class="text-xl font-semibold text-zinc-800">Proxmox Monitor</h1>
|
<header><span>proxmox-monitor</span><span class="mono dim">sign in</span></header>
|
||||||
|
<div class="body">
|
||||||
<%= if @error do %>
|
<%= if @error do %>
|
||||||
<p class="text-sm text-red-600">{@error}</p>
|
<div class="callout err">{@error}</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<form method="post" action="/login" class="stack sm">
|
||||||
<form method="post" action="/login" class="space-y-4">
|
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
|
||||||
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
|
|
||||||
|
|
||||||
<label class="block">
|
|
||||||
<span class="text-sm text-zinc-700">Password</span>
|
|
||||||
<input
|
<input
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
autofocus
|
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="btn primary" style="justify-content: center;">
|
||||||
|
sign in
|
||||||
<button
|
</button>
|
||||||
type="submit"
|
</form>
|
||||||
class="w-full rounded-md bg-zinc-900 text-white py-2 hover:bg-zinc-700"
|
</div>
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,13 @@ defmodule ServerWeb.AdminHostsLive do
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:hosts, Hosts.list_all())
|
|> assign(
|
||||||
|> assign(:new_token, nil)
|
hosts: Hosts.list_all(),
|
||||||
|> assign(:error, nil)}
|
form: to_form(%{"name" => ""}, as: :host),
|
||||||
|
new_token: nil,
|
||||||
|
error: nil,
|
||||||
|
page_title: "Admin"
|
||||||
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -18,9 +22,12 @@ defmodule ServerWeb.AdminHostsLive do
|
||||||
{:ok, {host, token}} ->
|
{:ok, {host, token}} ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:hosts, Hosts.list_all())
|
|> assign(
|
||||||
|> assign(:new_token, %{name: host.name, token: token})
|
hosts: Hosts.list_all(),
|
||||||
|> assign(:error, nil)}
|
form: to_form(%{"name" => ""}, as: :host),
|
||||||
|
new_token: %{name: host.name, token: token},
|
||||||
|
error: nil
|
||||||
|
)}
|
||||||
|
|
||||||
{:error, cs} ->
|
{:error, cs} ->
|
||||||
{:noreply, assign(socket, :error, changeset_message(cs))}
|
{:noreply, assign(socket, :error, changeset_message(cs))}
|
||||||
|
|
@ -33,8 +40,7 @@ defmodule ServerWeb.AdminHostsLive do
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:hosts, Hosts.list_all())
|
|> assign(hosts: Hosts.list_all(), new_token: %{name: host.name, token: token})}
|
||||||
|> assign(:new_token, %{name: host.name, token: token})}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("delete", %{"id" => id}, socket) do
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
|
|
@ -44,89 +50,93 @@ defmodule ServerWeb.AdminHostsLive do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp changeset_message(cs) do
|
defp changeset_message(cs) do
|
||||||
cs.errors
|
cs.errors |> Enum.map_join(", ", fn {k, {msg, _}} -> "#{k}: #{msg}" end)
|
||||||
|> Enum.map_join(", ", fn {k, {msg, _}} -> "#{k}: #{msg}" end)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<div class="p-6 max-w-4xl mx-auto space-y-6">
|
<ServerWeb.DashboardNav.nav active={:admin} />
|
||||||
<.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">
|
<div class="page">
|
||||||
<h2 class="font-semibold mb-2">Register a new host</h2>
|
<div class="pagehead">
|
||||||
<form phx-submit="create" class="flex gap-2">
|
<h1>Admin · Hosts</h1>
|
||||||
<input
|
<span class="sub">{length(@hosts)} registered</span>
|
||||||
name="host[name]"
|
</div>
|
||||||
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">
|
<div class="panel">
|
||||||
<p class="text-sm font-semibold text-amber-900">
|
<header><span>Register a host</span></header>
|
||||||
Token for {@new_token.name} (shown once):
|
<div class="body">
|
||||||
</p>
|
<.form for={@form} phx-submit="create" class="form-row">
|
||||||
<code class="block mt-1 break-all text-sm">{@new_token.token}</code>
|
<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>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
<section class="bg-white border rounded-lg">
|
<div class="panel">
|
||||||
<table class="w-full text-sm">
|
<header><span>All hosts</span></header>
|
||||||
<thead>
|
<div class="body tight">
|
||||||
<tr class="text-left text-zinc-500 border-b">
|
<table class="tbl" :if={@hosts != []}>
|
||||||
<th class="py-2 px-3">Name</th>
|
<thead>
|
||||||
<th class="py-2 px-3">Status</th>
|
<tr>
|
||||||
<th class="py-2 px-3">Agent</th>
|
<th>Name</th>
|
||||||
<th class="py-2 px-3">Last seen</th>
|
<th>Status</th>
|
||||||
<th class="py-2 px-3 text-right">Actions</th>
|
<th>Agent</th>
|
||||||
</tr>
|
<th>Last seen</th>
|
||||||
</thead>
|
<th class="right">Actions</th>
|
||||||
<tbody>
|
</tr>
|
||||||
<tr :for={h <- @hosts} class="border-b last:border-b-0">
|
</thead>
|
||||||
<td class="py-2 px-3 font-mono">{h.name}</td>
|
<tbody>
|
||||||
<td class="py-2 px-3">{h.status}</td>
|
<tr :for={h <- @hosts}>
|
||||||
<td class="py-2 px-3">{h.agent_version || "—"}</td>
|
<td class="mono">
|
||||||
<td class="py-2 px-3">{format_seen(h.last_seen_at)}</td>
|
<.link navigate={~p"/hosts/#{h.name}"}>{h.name}</.link>
|
||||||
<td class="py-2 px-3 text-right space-x-2">
|
</td>
|
||||||
<button
|
<td><span class="badge" data-status={h.status}>{h.status}</span></td>
|
||||||
phx-click="rotate"
|
<td class="mono">{h.agent_version || "—"}</td>
|
||||||
phx-value-id={h.id}
|
<td class="mono">{format_seen(h.last_seen_at)}</td>
|
||||||
class="text-xs text-zinc-700 underline"
|
<td class="right">
|
||||||
data-confirm={"Rotate token for #{h.name}? Old token will stop working."}
|
<button
|
||||||
>
|
type="button"
|
||||||
Rotate
|
phx-click="rotate"
|
||||||
</button>
|
phx-value-id={h.id}
|
||||||
<button
|
class="btn sm ghost"
|
||||||
phx-click="delete"
|
data-confirm={"Rotate token for #{h.name}? Old token stops working immediately."}
|
||||||
phx-value-id={h.id}
|
>
|
||||||
class="text-xs text-red-600 underline"
|
rotate
|
||||||
data-confirm={"Delete #{h.name} and all its metrics?"}
|
</button>
|
||||||
>
|
<button
|
||||||
Delete
|
type="button"
|
||||||
</button>
|
phx-click="delete"
|
||||||
</td>
|
phx-value-id={h.id}
|
||||||
</tr>
|
class="btn sm danger"
|
||||||
<tr :if={@hosts == []}>
|
data-confirm={"Delete #{h.name} and all its metrics?"}
|
||||||
<td colspan="5" class="py-4 px-3 text-center text-zinc-500">
|
>
|
||||||
No hosts yet.
|
delete
|
||||||
</td>
|
</button>
|
||||||
</tr>
|
</td>
|
||||||
</tbody>
|
</tr>
|
||||||
</table>
|
</tbody>
|
||||||
</section>
|
</table>
|
||||||
|
<div :if={@hosts == []} class="empty">No hosts yet.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
defp format_seen(nil), do: "never"
|
defp format_seen(nil), do: "never"
|
||||||
|
defp format_seen(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M UTC")
|
||||||
defp format_seen(%DateTime{} = dt) do
|
|
||||||
Calendar.strftime(dt, "%Y-%m-%d %H:%M UTC")
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ defmodule ServerWeb.HostDetailLive do
|
||||||
if connected?(socket),
|
if connected?(socket),
|
||||||
do: Phoenix.PubSub.subscribe(Server.PubSub, "metrics:#{host.id}")
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -38,141 +38,129 @@ defmodule ServerWeb.HostDetailLive do
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<div class="p-6 max-w-6xl mx-auto space-y-6">
|
<ServerWeb.DashboardNav.nav active={:overview} />
|
||||||
<header class="flex justify-between items-center">
|
|
||||||
|
<div class="page">
|
||||||
|
<div class="pagehead">
|
||||||
<div>
|
<div>
|
||||||
<.link navigate={~p"/"} class="text-sm text-zinc-500 hover:text-zinc-900">← Back</.link>
|
<h1>{@host.name}</h1>
|
||||||
<h1 class="text-2xl font-bold">{@host.name}</h1>
|
<div class="sub">
|
||||||
<p class="text-sm text-zinc-600">
|
{sys_line(@slow)} · uptime {uptime(@fast)} · last seen {last_seen(@host.last_seen_at)}
|
||||||
{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"}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<span class="badge" data-status={@host.status}>{@host.status}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="bg-white border rounded-lg p-4">
|
<div class="panel">
|
||||||
<h2 class="font-semibold text-zinc-800 mb-2">Snapshots</h2>
|
<header><span>Host</span></header>
|
||||||
<table class="w-full text-sm">
|
<div class="body">
|
||||||
<thead>
|
<dl class="kv">
|
||||||
<tr class="text-left text-zinc-500 border-b">
|
<dt>load (1/5/15)</dt><dd>{host_load(@fast)}</dd>
|
||||||
<th class="py-1 pr-4">Dataset</th>
|
<dt>memory</dt><dd>{host_mem(@fast)}</dd>
|
||||||
<th class="py-1 pr-4">Count</th>
|
<dt>agent</dt><dd>{@host.agent_version || "—"}</dd>
|
||||||
<th class="py-1 pr-4">Oldest</th>
|
</dl>
|
||||||
<th class="py-1 pr-4">Newest</th>
|
</div>
|
||||||
</tr>
|
</div>
|
||||||
</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>
|
|
||||||
|
|
||||||
<section class="bg-white border rounded-lg p-4">
|
<div class="panel">
|
||||||
<h2 class="font-semibold text-zinc-800 mb-2">Storage</h2>
|
<header><span>ZFS pools</span><span class="mono">{length(pools(@fast))}</span></header>
|
||||||
<table class="w-full text-sm">
|
<div class="body tight">
|
||||||
<thead>
|
<div :if={pools(@fast) == []} class="empty">No data.</div>
|
||||||
<tr class="text-left text-zinc-500 border-b">
|
<div :for={pool <- pools(@fast)} class="pool-row" style="padding: 0.6rem 0.9rem;">
|
||||||
<th class="py-1 pr-4">Name</th>
|
<div>
|
||||||
<th class="py-1 pr-4">Type</th>
|
<span class="mono" style="color: var(--fg-bright); font-weight: 600;">
|
||||||
<th class="py-1 pr-4">Usage</th>
|
{pool["name"]}
|
||||||
</tr>
|
</span>
|
||||||
</thead>
|
<span class="badge" style={pool_badge_style(pool["health"])}>{pool["health"]}</span>
|
||||||
<tbody>
|
<div class="details">
|
||||||
<tr :for={s <- storages(@fast)} class="border-b last:border-b-0">
|
cap {pool["capacity_percent"]}% ·
|
||||||
<td class="py-1 font-mono">{s["name"]}</td>
|
frag {pool["fragmentation_percent"] || 0}% ·
|
||||||
<td class="py-1">{s["type"]}</td>
|
err {pool["error_count"] || 0} ·
|
||||||
<td class="py-1">{storage_usage(s)}</td>
|
vdevs {pool["vdev_count"] || 0} (deg {pool["degraded_vdev_count"] || 0})
|
||||||
</tr>
|
</div>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
<div class="mono muted" style="font-size: 0.75rem; align-self: center; text-align: right;">
|
||||||
<p :if={storages(@fast) == []} class="text-sm text-zinc-500">No data.</p>
|
scrub<br/>{pool["last_scrub_end"] || "never"}
|
||||||
</section>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="bg-white border rounded-lg p-4">
|
<div class="panel">
|
||||||
<h2 class="font-semibold text-zinc-800 mb-2">VMs / LXCs</h2>
|
<header><span>Snapshots</span><span class="mono">{length(datasets(@medium))}</span></header>
|
||||||
<table class="w-full text-sm">
|
<div class="body tight">
|
||||||
<thead>
|
<table class="tbl" :if={datasets(@medium) != []}>
|
||||||
<tr class="text-left text-zinc-500 border-b">
|
<thead>
|
||||||
<th class="py-1 pr-4">VMID</th>
|
<tr><th>Dataset</th><th>Count</th><th>Oldest</th><th>Newest</th></tr>
|
||||||
<th class="py-1 pr-4">Name</th>
|
</thead>
|
||||||
<th class="py-1 pr-4">Type</th>
|
<tbody>
|
||||||
<th class="py-1 pr-4">Status</th>
|
<tr :for={ds <- datasets(@medium)}>
|
||||||
</tr>
|
<td class="mono">{ds["name"]}</td>
|
||||||
</thead>
|
<td class="num">{ds["snapshot_count"]}</td>
|
||||||
<tbody>
|
<td class="mono">{unix_to_date(ds["oldest_snapshot_unix"])}</td>
|
||||||
<tr :for={vm <- vms(@fast)} class="border-b last:border-b-0">
|
<td class="mono">{unix_to_date(ds["newest_snapshot_unix"])}</td>
|
||||||
<td class="py-1">{vm["vmid"]}</td>
|
</tr>
|
||||||
<td class="py-1 font-mono">{vm["name"]}</td>
|
</tbody>
|
||||||
<td class="py-1">{vm["type"]}</td>
|
</table>
|
||||||
<td class="py-1">{vm["status"]}</td>
|
<div :if={datasets(@medium) == []} class="empty">No data.</div>
|
||||||
</tr>
|
</div>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
|
||||||
<p :if={vms(@fast) == []} class="text-sm text-zinc-500">No data.</p>
|
<div class="panel">
|
||||||
</section>
|
<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>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
attr :label, :string, required: true
|
defp vm_status(%{"status" => "running"}), do: "ok"
|
||||||
attr :value, :string, required: true
|
defp vm_status(%{"status" => "stopped"}), do: "offline"
|
||||||
|
defp vm_status(_), do: "warning"
|
||||||
|
|
||||||
def metric_row(assigns) do
|
defp pool_badge_style("ONLINE"), do: "color: var(--ok);"
|
||||||
~H"""
|
defp pool_badge_style(_), do: "color: var(--crit);"
|
||||||
<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 sys_line(nil), do: "—"
|
defp sys_line(nil), do: "—"
|
||||||
|
|
||||||
defp sys_line(%{payload: p}) do
|
defp sys_line(%{payload: p}) do
|
||||||
get_in(p, ["system_info", "pve_version"]) || "—"
|
get_in(p, ["system_info", "pve_version"]) || "—"
|
||||||
end
|
end
|
||||||
|
|
||||||
defp uptime(nil), do: "—"
|
defp uptime(nil), do: "—"
|
||||||
|
|
||||||
defp uptime(%{payload: p}) do
|
defp uptime(%{payload: p}) do
|
||||||
case get_in(p, ["host", "uptime_seconds"]) do
|
case get_in(p, ["host", "uptime_seconds"]) do
|
||||||
nil -> "—"
|
nil -> "—"
|
||||||
|
|
@ -182,10 +170,8 @@ defmodule ServerWeb.HostDetailLive do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp last_seen(nil), do: "never"
|
defp last_seen(nil), do: "never"
|
||||||
|
|
||||||
defp last_seen(%DateTime{} = dt) do
|
defp last_seen(%DateTime{} = dt) do
|
||||||
secs = DateTime.diff(DateTime.utc_now(), dt, :second)
|
secs = DateTime.diff(DateTime.utc_now(), dt, :second)
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
secs < 60 -> "#{secs}s ago"
|
secs < 60 -> "#{secs}s ago"
|
||||||
secs < 3600 -> "#{div(secs, 60)}m ago"
|
secs < 3600 -> "#{div(secs, 60)}m ago"
|
||||||
|
|
@ -194,7 +180,6 @@ defmodule ServerWeb.HostDetailLive do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp host_load(nil), do: "—"
|
defp host_load(nil), do: "—"
|
||||||
|
|
||||||
defp host_load(%{payload: p}) do
|
defp host_load(%{payload: p}) do
|
||||||
l1 = get_in(p, ["host", "load1"]) || "—"
|
l1 = get_in(p, ["host", "load1"]) || "—"
|
||||||
l5 = get_in(p, ["host", "load5"]) || "—"
|
l5 = get_in(p, ["host", "load5"]) || "—"
|
||||||
|
|
@ -203,7 +188,6 @@ defmodule ServerWeb.HostDetailLive do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp host_mem(nil), do: "—"
|
defp host_mem(nil), do: "—"
|
||||||
|
|
||||||
defp host_mem(%{payload: p}) do
|
defp host_mem(%{payload: p}) do
|
||||||
used = get_in(p, ["host", "mem_used_bytes"])
|
used = get_in(p, ["host", "mem_used_bytes"])
|
||||||
total = get_in(p, ["host", "mem_total_bytes"])
|
total = get_in(p, ["host", "mem_total_bytes"])
|
||||||
|
|
@ -211,9 +195,7 @@ defmodule ServerWeb.HostDetailLive do
|
||||||
case {used, total} do
|
case {used, total} do
|
||||||
{u, t} when is_integer(u) and is_integer(t) and t > 0 ->
|
{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)})"
|
"#{Float.round(u / t * 100, 1)}% (#{format_bytes(u)} / #{format_bytes(t)})"
|
||||||
|
_ -> "—"
|
||||||
_ ->
|
|
||||||
"—"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -237,7 +219,6 @@ defmodule ServerWeb.HostDetailLive do
|
||||||
defp storage_usage(_), do: "—"
|
defp storage_usage(_), do: "—"
|
||||||
|
|
||||||
defp unix_to_date(nil), do: "—"
|
defp unix_to_date(nil), do: "—"
|
||||||
|
|
||||||
defp unix_to_date(unix) when is_integer(unix) do
|
defp unix_to_date(unix) when is_integer(unix) do
|
||||||
case DateTime.from_unix(unix) do
|
case DateTime.from_unix(unix) do
|
||||||
{:ok, dt} -> Calendar.strftime(dt, "%Y-%m-%d")
|
{:ok, dt} -> Calendar.strftime(dt, "%Y-%m-%d")
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ defmodule ServerWeb.OverviewLive do
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
if connected?(socket), do: Phoenix.PubSub.subscribe(Server.PubSub, "metrics")
|
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
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -25,70 +25,59 @@ defmodule ServerWeb.OverviewLive do
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<div class="p-6 max-w-6xl mx-auto space-y-6">
|
<ServerWeb.DashboardNav.nav active={:overview} />
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="page">
|
||||||
<div
|
<div class="pagehead">
|
||||||
:for={entry <- @hosts}
|
<h1>Hosts</h1>
|
||||||
data-role="host-card"
|
<span class="sub">{summary_line(@hosts)}</span>
|
||||||
data-status={Atom.to_string(entry.status)}
|
</div>
|
||||||
class={"p-4 rounded-lg border-l-4 bg-white shadow-sm " <> border_class(entry.status)}
|
|
||||||
>
|
<div :if={@hosts == []} class="panel">
|
||||||
<.link navigate={~p"/hosts/#{entry.host.name}"} class="block space-y-2">
|
<div class="empty">
|
||||||
<div class="flex justify-between items-baseline">
|
No hosts registered. Add one at
|
||||||
<span class="font-semibold text-zinc-900">{entry.host.name}</span>
|
<.link navigate={~p"/admin/hosts"}>/admin/hosts</.link>.
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p :if={@hosts == []} class="text-zinc-500">
|
<div class="cards">
|
||||||
No hosts registered yet. Add one via <code>/admin/hosts</code>.
|
<.link :for={entry <- @hosts} navigate={~p"/hosts/#{entry.host.name}"} class="card"
|
||||||
</p>
|
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>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
defp border_class(:ok), do: "border-green-500"
|
defp summary_line([]), do: "0 hosts"
|
||||||
defp border_class(:warning), do: "border-yellow-500"
|
defp summary_line(hosts) do
|
||||||
defp border_class(:critical), do: "border-red-500"
|
n = length(hosts)
|
||||||
defp border_class(:offline), do: "border-zinc-400"
|
by = Enum.frequencies_by(hosts, & &1.status)
|
||||||
|
parts =
|
||||||
defp text_class(:ok), do: "text-green-600"
|
[:ok, :warning, :critical, :offline]
|
||||||
defp text_class(:warning), do: "text-yellow-600"
|
|> Enum.filter(&Map.has_key?(by, &1))
|
||||||
defp text_class(:critical), do: "text-red-600"
|
|> Enum.map(fn s -> "#{by[s]} #{s}" end)
|
||||||
defp text_class(:offline), do: "text-zinc-500"
|
"#{n} host#{if n == 1, do: "", else: "s"} · " <> Enum.join(parts, " · ")
|
||||||
|
end
|
||||||
|
|
||||||
defp last_seen(nil), do: "never"
|
defp last_seen(nil), do: "never"
|
||||||
|
|
||||||
defp last_seen(%DateTime{} = dt) do
|
defp last_seen(%DateTime{} = dt) do
|
||||||
secs = DateTime.diff(DateTime.utc_now(), dt, :second)
|
secs = DateTime.diff(DateTime.utc_now(), dt, :second)
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
secs < 60 -> "#{secs}s ago"
|
secs < 60 -> "#{secs}s ago"
|
||||||
secs < 3600 -> "#{div(secs, 60)}m ago"
|
secs < 3600 -> "#{div(secs, 60)}m ago"
|
||||||
|
|
@ -96,23 +85,20 @@ defmodule ServerWeb.OverviewLive do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp format_load(payload) do
|
defp load(payload) do
|
||||||
case get_in(payload, ["host", "load1"]) do
|
case get_in(payload, ["host", "load1"]) do
|
||||||
nil -> "—"
|
nil -> "—"
|
||||||
l -> :io_lib.format("~.2f", [l]) |> to_string()
|
l -> :io_lib.format("~.2f", [l]) |> to_string()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp format_mem(payload) do
|
defp mem(payload) do
|
||||||
used = get_in(payload, ["host", "mem_used_bytes"])
|
used = get_in(payload, ["host", "mem_used_bytes"])
|
||||||
total = get_in(payload, ["host", "mem_total_bytes"])
|
total = get_in(payload, ["host", "mem_total_bytes"])
|
||||||
|
|
||||||
case {used, total} do
|
case {used, total} do
|
||||||
{u, t} when is_integer(u) and is_integer(t) and t > 0 ->
|
{u, t} when is_integer(u) and is_integer(t) and t > 0 ->
|
||||||
"#{Float.round(u / t * 100, 1)}%"
|
"#{Float.round(u / t * 100, 1)}%"
|
||||||
|
_ -> "—"
|
||||||
_ ->
|
|
||||||
"—"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -120,11 +106,11 @@ defmodule ServerWeb.OverviewLive do
|
||||||
pools = get_in(payload, ["zfs_pools", "pools"]) || []
|
pools = get_in(payload, ["zfs_pools", "pools"]) || []
|
||||||
total = length(pools)
|
total = length(pools)
|
||||||
bad = Enum.count(pools, &(&1["health"] != "ONLINE"))
|
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
|
end
|
||||||
|
|
||||||
defp vm_count(payload) do
|
defp vm_count(payload) do
|
||||||
vms = get_in(payload, ["vms_runtime", "vms"]) || []
|
vms = get_in(payload, ["vms_runtime", "vms"]) || []
|
||||||
length(vms)
|
length(vms) |> to_string()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ defmodule ServerWeb.VmSearchLive do
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
if connected?(socket), do: Phoenix.PubSub.subscribe(Server.PubSub, "metrics")
|
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
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -54,51 +54,57 @@ defmodule ServerWeb.VmSearchLive do
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<div class="p-6 max-w-6xl mx-auto space-y-4">
|
<ServerWeb.DashboardNav.nav active={:vms} />
|
||||||
<.link navigate={~p"/"} class="text-sm text-zinc-500 hover:text-zinc-900">← Back</.link>
|
|
||||||
<h1 class="text-2xl font-bold">VM Search</h1>
|
<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">
|
<form phx-change="search">
|
||||||
<input
|
<input
|
||||||
name="q"
|
name="q"
|
||||||
value={@q}
|
value={@q}
|
||||||
placeholder="Search by name or IP…"
|
placeholder="name or ip…"
|
||||||
|
autocomplete="off"
|
||||||
autofocus
|
autofocus
|
||||||
class="w-full rounded-md border-zinc-300 focus:border-zinc-400 focus:ring-0"
|
class="input"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<table class="w-full text-sm bg-white border rounded-lg">
|
<div class="panel">
|
||||||
<thead>
|
<div class="body tight">
|
||||||
<tr class="text-left text-zinc-500 border-b">
|
<table class="tbl" :if={filter(@vms, @q) != []}>
|
||||||
<th class="py-2 px-3">Name</th>
|
<thead>
|
||||||
<th class="py-2 px-3">Host</th>
|
<tr>
|
||||||
<th class="py-2 px-3">Type</th>
|
<th>VMID</th>
|
||||||
<th class="py-2 px-3">Status</th>
|
<th>Name</th>
|
||||||
<th class="py-2 px-3">IPs</th>
|
<th>Host</th>
|
||||||
</tr>
|
<th>Type</th>
|
||||||
</thead>
|
<th>Status</th>
|
||||||
<tbody>
|
<th>IPs</th>
|
||||||
<tr :for={vm <- filter(@vms, @q)} class="border-b last:border-b-0">
|
</tr>
|
||||||
<td class="py-2 px-3 font-mono">{vm.name}</td>
|
</thead>
|
||||||
<td class="py-2 px-3">
|
<tbody>
|
||||||
<.link
|
<tr :for={vm <- filter(@vms, @q)}>
|
||||||
navigate={~p"/hosts/#{vm.host_name}"}
|
<td class="num">{vm.vmid}</td>
|
||||||
class="text-zinc-700 hover:text-zinc-900 underline"
|
<td class="mono">{vm.name}</td>
|
||||||
>
|
<td><.link navigate={~p"/hosts/#{vm.host_name}"}>{vm.host_name}</.link></td>
|
||||||
{vm.host_name}
|
<td>{vm.type}</td>
|
||||||
</.link>
|
<td><span class="badge" data-status={vm_status(vm)}>{vm.status}</span></td>
|
||||||
</td>
|
<td class="mono" style="font-size: 0.75rem;">{Enum.join(vm.ips, ", ")}</td>
|
||||||
<td class="py-2 px-3">{vm.type}</td>
|
</tr>
|
||||||
<td class="py-2 px-3">{vm.status}</td>
|
</tbody>
|
||||||
<td class="py-2 px-3 font-mono text-xs">{Enum.join(vm.ips, ", ")}</td>
|
</table>
|
||||||
</tr>
|
<div :if={filter(@vms, @q) == []} class="empty">No matches.</div>
|
||||||
<tr :if={filter(@vms, @q) == []}>
|
</div>
|
||||||
<td colspan="5" class="py-4 px-3 text-center text-zinc-500">No matches.</td>
|
</div>
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp vm_status(%{status: "running"}), do: "ok"
|
||||||
|
defp vm_status(%{status: "stopped"}), do: "offline"
|
||||||
|
defp vm_status(_), do: "warning"
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue