feat: Updated server page
Chart Release / chart (push) Successful in 22s
Server Deploy / deploy (push) Successful in 45s

This commit is contained in:
2026-08-07 16:21:17 +01:00
parent 0684d84609
commit d559cccd44
11 changed files with 1198 additions and 538 deletions
+148
View File
@@ -0,0 +1,148 @@
"use client";
import { useState } from "react";
import { GenerateKeyOptions } from "@/lib/api";
import { Button } from "@/components/ui";
const KEY_SIZES: Record<string, number[]> = {
rsa: [2048, 3072, 4096],
ecdsa: [256, 384, 521],
};
const DEFAULT_SIZE: Record<string, number> = {
rsa: 4096,
ecdsa: 256,
};
export function GenerateKeyModal({ onClose, onSubmit, isPending }: { onClose: () => void; onSubmit: (opts: GenerateKeyOptions) => void; isPending: boolean }) {
const [label, setLabel] = useState("");
const [keyType, setKeyType] = useState<"ed25519" | "rsa" | "ecdsa">("ed25519");
const [keySize, setKeySize] = useState<number>(4096);
const [passphrase, setPassphrase] = useState("");
const [comment, setComment] = useState("");
function handleKeyTypeChange(t: "ed25519" | "rsa" | "ecdsa") {
setKeyType(t);
if (t !== "ed25519") {
setKeySize(DEFAULT_SIZE[t]);
}
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
onSubmit({
label: label || "generated",
key_type: keyType,
key_size: keyType !== "ed25519" ? keySize : undefined,
passphrase: passphrase || undefined,
comment: comment || undefined,
});
}
const sizes = KEY_SIZES[keyType];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative z-10 w-full max-w-md rounded-xl border border-border bg-surface p-6 shadow-2xl">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-lg font-semibold text-text-primary">Generate SSH Key</h2>
<button onClick={onClose} className="rounded-md p-1 text-text-secondary transition-colors hover:text-text-primary">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Label <span className="text-text-tertiary">(used as the key name in Vantage)</span>
</label>
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g. server-deploy-key"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Type</label>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{(["ed25519", "rsa", "ecdsa"] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => handleKeyTypeChange(t)}
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
keyType === t ? "border-accent bg-accent/10 text-accent" : "border-border bg-surface-2 text-text-secondary hover:border-accent/40 hover:text-text-primary"
}`}
>
{t}
</button>
))}
</div>
{keyType === "ed25519" && <p className="mt-1.5 text-xs text-text-tertiary">Modern, fast, and secure. Recommended for new keys.</p>}
{keyType === "rsa" && <p className="mt-1.5 text-xs text-text-tertiary">Widely compatible with older systems.</p>}
{keyType === "ecdsa" && <p className="mt-1.5 text-xs text-text-tertiary">Elliptic curve shorter keys, good compatibility.</p>}
</div>
{sizes && (
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Size (bits)</label>
<select
value={keySize}
onChange={(e) => setKeySize(Number(e.target.value))}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
>
{sizes.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
)}
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Comment <span className="text-text-tertiary">(embedded in the public key)</span>
</label>
<input
type="text"
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="e.g. user@hostname"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Passphrase <span className="text-text-tertiary">(leave blank for no passphrase)</span>
</label>
<input
type="password"
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
placeholder="Optional passphrase"
autoComplete="new-password"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
<div className="flex gap-3 pt-1">
<Button type="submit" variant="primary" loading={isPending} className="flex-1">
Generate Key
</Button>
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,131 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { clsx } from "clsx";
import { Button } from "@/components/ui";
/*
* Every action this page can take, behind one control.
*
* The header used to carry up to six buttons — a Connect per console protocol,
* OS updates, Generate key, Remove — and which of them appeared depended on the
* server, so the row an operator reached for moved between machines. One button
* in one place is worth more than a shortcut that is sometimes there.
*
* An action the licence or the host does not allow is rendered disabled with a
* reason rather than hidden: a customer cannot buy what they cannot see, and a
* control that vanishes reads as a bug.
*/
export interface ServerAction {
/** Grouping heading. Items sharing one carry it once, on the first. */
group?: string;
label: string;
icon: React.ReactNode;
onSelect?: () => void;
href?: string;
disabled?: boolean;
/** Why it is disabled, or what it will do. Shown as the native tooltip. */
title?: string;
danger?: boolean;
/** Draws a rule above this item. */
separated?: boolean;
}
export function ServerActionsMenu({ actions }: { actions: ServerAction[] }) {
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const router = useRouter();
useEffect(() => {
if (!open) return;
function onPointerDown(e: MouseEvent) {
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
setOpen(false);
buttonRef.current?.focus();
}
}
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKey);
menuRef.current?.querySelector<HTMLElement>("[role=menuitem]:not([aria-disabled=true])")?.focus();
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
function onMenuKeyDown(e: React.KeyboardEvent) {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
e.preventDefault();
const items = [...(menuRef.current?.querySelectorAll<HTMLElement>("[role=menuitem]:not([aria-disabled=true])") ?? [])];
const i = items.indexOf(document.activeElement as HTMLElement);
items[(i + (e.key === "ArrowDown" ? 1 : -1) + items.length) % items.length]?.focus();
}
function run(action: ServerAction) {
if (action.disabled) return;
setOpen(false);
buttonRef.current?.focus();
if (action.href) router.push(action.href);
action.onSelect?.();
}
return (
<div ref={wrapRef} className="relative">
<Button ref={buttonRef} variant="primary" aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen((v) => !v)}>
Actions
<svg className={clsx("h-4 w-4 transition-transform", open && "rotate-180")} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 9l6 6 6-6" />
</svg>
</Button>
{open && (
<div
ref={menuRef}
role="menu"
aria-label="Server actions"
onKeyDown={onMenuKeyDown}
className="absolute right-0 z-50 mt-1.5 w-60 rounded border border-border bg-surface p-1 shadow-panel"
>
{actions.map((action, i) => (
<div key={action.label}>
{action.separated && i > 0 && <div className="my-1 h-px bg-border-soft" />}
{action.group && <p className="px-2 pb-1 pt-1.5 font-mono text-[0.62rem] uppercase tracking-[0.16em] text-text-tertiary">{action.group}</p>}
<button
type="button"
role="menuitem"
aria-disabled={action.disabled}
disabled={action.disabled}
title={action.title}
onClick={() => run(action)}
className={clsx(
"flex w-full items-center gap-2.5 rounded px-2 py-2 text-left text-sm font-medium transition-colors",
action.disabled
? "cursor-not-allowed text-text-tertiary"
: action.danger
? "text-danger hover:bg-danger/10"
: "text-text-primary hover:bg-surface-2 [&>svg]:hover:text-accent",
!action.disabled && !action.danger && "[&>svg]:text-text-tertiary",
action.danger && "[&>svg]:text-danger",
action.disabled && "[&>svg]:text-text-tertiary",
)}
>
{action.icon}
{action.label}
</button>
</div>
))}
</div>
)}
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useRef } from "react";
import { clsx } from "clsx";
/*
* The tab bar under the faceplate.
*
* Below md it is a select instead. Five tabs do not fit a phone, and the two
* usual answers are both worse: wrapping to a second row changes the height of
* the sticky header as the selection moves, and a horizontally scrolling strip
* hides tabs off the right edge with nothing saying they are there. A select
* shows every section and its count in one list, and it is the platform's own
* picker, so it needs no scroll affordance of ours.
*
* Counts live on the labels because that is the only way an operator learns
* there is something wrong on a tab they are not looking at. A count with a
* tone is still labelled by its tab name, so tone is never the whole message —
* and in the select, where tone cannot survive, the count still does.
*/
export type TabId = "overview" | "workloads" | "security" | "access" | "maintenance";
export interface TabSpec {
id: TabId;
label: string;
count?: number;
tone?: "neutral" | "warning" | "danger";
}
export function ServerTabs({ tabs, active, onSelect }: { tabs: TabSpec[]; active: TabId; onSelect: (id: TabId) => void }) {
const refs = useRef<Record<string, HTMLButtonElement | null>>({});
function onKeyDown(e: React.KeyboardEvent) {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
e.preventDefault();
const i = tabs.findIndex((t) => t.id === active);
const next = tabs[(i + (e.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length];
onSelect(next.id);
refs.current[next.id]?.focus();
}
const current = tabs.find((t) => t.id === active);
return (
<>
{/* Phone: the whole set in one picker, sitting on the same row as the
section it names so the header keeps its height. */}
<div className="pb-3 md:hidden">
<label htmlFor="server-tab-select" className="sr-only">
Server section
</label>
<div className="relative">
<select
id="server-tab-select"
value={active}
onChange={(e) => onSelect(e.target.value as TabId)}
className="w-full appearance-none rounded border border-border bg-surface-2 py-2 pl-3 pr-9 text-sm font-semibold text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
>
{tabs.map((tab) => (
<option key={tab.id} value={tab.id}>
{tab.label}
{tab.count !== undefined && tab.count > 0 ? ` (${tab.count})` : ""}
</option>
))}
</select>
<svg className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 9l6 6 6-6" />
</svg>
</div>
{current?.count !== undefined && current.count > 0 && current.tone && current.tone !== "neutral" && (
<p className={clsx("mt-1.5 text-xs", current.tone === "danger" ? "text-danger" : "text-warning")}>
{current.count} item{current.count !== 1 ? "s" : ""} in {current.label.toLowerCase()}
</p>
)}
</div>
<div role="tablist" aria-label="Server sections" onKeyDown={onKeyDown} className="-mb-px hidden gap-1 md:flex">
{tabs.map((tab) => {
const isActive = tab.id === active;
return (
<button
key={tab.id}
ref={(el) => {
refs.current[tab.id] = el;
}}
type="button"
role="tab"
id={`server-tab-${tab.id}`}
aria-selected={isActive}
aria-controls={`server-panel-${tab.id}`}
tabIndex={isActive ? 0 : -1}
onClick={() => onSelect(tab.id)}
className={clsx(
"flex shrink-0 items-center gap-2 whitespace-nowrap border-b-2 px-3 py-2.5 text-sm transition-colors",
isActive ? "border-accent font-semibold text-text-primary" : "border-transparent font-medium text-text-secondary hover:text-text-primary",
)}
>
{tab.label}
{tab.count !== undefined && tab.count > 0 && (
<span
className={clsx(
"rounded-full border px-1.5 py-px font-mono text-[0.62rem] tabular-nums",
tab.tone === "danger"
? "border-danger/40 bg-danger/10 text-danger"
: tab.tone === "warning"
? "border-warning/40 bg-warning/10 text-warning"
: "border-border bg-surface-2 text-text-secondary",
)}
>
{tab.count}
</span>
)}
</button>
);
})}
</div>
</>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { clsx } from "clsx";
import { Inventory, Server } from "@/lib/api";
import { formatBytes, relativeAge } from "./format";
/*
* The four numbers an operator opens a server for, kept above the tabs so they
* are true on every tab rather than living inside one of them. This is the only
* part of the page that does not move when the tab changes.
*
* A meter is a hairline, not a bar: four of them across the top would otherwise
* out-shout the hostname, and the number beside each is the value being read —
* the meter only says how close to full it is.
*/
function Meter({ pct }: { pct: number }) {
const clamped = Math.max(0, Math.min(100, pct));
return (
<div className="mt-2 h-[3px] w-full overflow-hidden rounded-full bg-well">
<div
className={clsx("h-full rounded-full transition-[width] duration-500", clamped >= 90 ? "bg-danger" : clamped >= 75 ? "bg-warning" : "bg-accent")}
style={{ width: `${clamped}%` }}
/>
</div>
);
}
function Vital({ label, value, pct, sub }: { label: string; value: string; pct?: number; sub?: string }) {
return (
<div className="min-w-0 bg-surface px-4 py-3">
<div className="flex items-baseline justify-between gap-3">
<span className="font-mono text-[0.62rem] uppercase tracking-[0.16em] text-text-secondary">{label}</span>
<span className="font-mono text-sm font-semibold tabular-nums text-text-primary">{value}</span>
</div>
{pct !== undefined ? <Meter pct={pct} /> : <div className="mt-2 h-[3px] w-full rounded-full bg-well" />}
{sub && <p className="mt-1.5 truncate text-xs text-text-tertiary">{sub}</p>}
</div>
);
}
/** The partition an operator means by "the disk": the root filesystem, or the
* fullest one if there is no root — a Windows agent reports no `/`. */
function primaryPartition(inv: Inventory) {
const parts = inv.partitions ?? [];
if (parts.length === 0) return undefined;
return parts.find((p) => p.mountpoint === "/") ?? parts.reduce((worst, p) => (p.used_bytes / (p.total_bytes || 1) > worst.used_bytes / (worst.total_bytes || 1) ? p : worst));
}
export function VitalsRail({ server, agentUpToDate }: { server: Server; agentUpToDate?: boolean }) {
const inv = server.inventory;
const disk = inv ? primaryPartition(inv) : undefined;
const memPct = inv && inv.memory.total_bytes > 0 ? (inv.memory.used_bytes / inv.memory.total_bytes) * 100 : 0;
const diskPct = disk && disk.total_bytes > 0 ? (disk.used_bytes / disk.total_bytes) * 100 : 0;
const agentSub = server.agent_version ? `agent v${server.agent_version}${agentUpToDate === undefined ? "" : agentUpToDate ? " · up to date" : " · update available"}` : "agent version unknown";
return (
// One hairline grid rather than four cards: these are readings off one
// machine, and four bordered panels would read as four subjects.
<div className="mt-4 grid grid-cols-2 gap-px overflow-hidden rounded border border-border-soft bg-border-soft lg:grid-cols-4">
{inv ? (
<>
<Vital
label="CPU"
value={`${inv.cpu.usage_pct.toFixed(0)}%`}
pct={inv.cpu.usage_pct}
sub={[inv.cpu.cores ? `${inv.cpu.cores} cores` : null, inv.cpu.load1 !== undefined ? `load ${inv.cpu.load1.toFixed(2)}` : null].filter(Boolean).join(" · ") || inv.cpu.model}
/>
<Vital
label="Memory"
value={`${formatBytes(inv.memory.used_bytes)} / ${formatBytes(inv.memory.total_bytes)}`}
pct={memPct}
sub={inv.swap_total_bytes > 0 ? `swap ${formatBytes(inv.swap_used_bytes)} / ${formatBytes(inv.swap_total_bytes)}` : "no swap"}
/>
<Vital
label={disk ? `Disk ${disk.mountpoint}` : "Disk"}
value={disk ? `${diskPct.toFixed(0)}%` : "—"}
pct={disk ? diskPct : undefined}
sub={disk ? `${formatBytes(disk.used_bytes)} / ${formatBytes(disk.total_bytes)}${disk.fstype ? ` · ${disk.fstype}` : ""}` : "no partitions reported"}
/>
</>
) : (
<>
<Vital label="CPU" value="—" sub="no metrics reported" />
<Vital label="Memory" value="—" sub="no metrics reported" />
<Vital label="Disk" value="—" sub="no metrics reported" />
</>
)}
<Vital label="Last seen" value={relativeAge(server.last_seen)} pct={server.status === "active" ? 100 : 0} sub={agentSub} />
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
/** Formatting shared by the server detail panels. One copy, because the rail
* and the storage panel must round the same bytes the same way. */
export function formatBytes(n: number): string {
if (!n) return "0 B";
const u = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(n) / Math.log(1024));
return `${(n / Math.pow(1024, i)).toFixed(1)} ${u[i]}`;
}
export function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString();
}
export function relativeAge(iso?: string): string {
if (!iso) return "never";
const secs = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
if (secs < 60) return `${Math.round(secs)}s ago`;
if (secs < 3600) return `${Math.round(secs / 60)}m ago`;
if (secs < 86_400) return `${Math.round(secs / 3600)}h ago`;
return `${Math.round(secs / 86_400)}d ago`;
}
+72
View File
@@ -0,0 +1,72 @@
/** The line icons the server actions menu uses. Heroicons outline, 1.5 stroke,
* the same set and weight the sidebar draws. */
const props = { className: "h-4 w-4 shrink-0 transition-colors", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.5 } as const;
export function ConsoleIcon() {
return (
<svg {...props}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"
/>
</svg>
);
}
export function KeyIcon() {
return (
<svg {...props}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
);
}
export function ArrowUpCircleIcon() {
return (
<svg {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9.75l-3 3m3-3l3 3m-3-3v7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
}
export function ShieldIcon() {
return (
<svg {...props}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.998 2.25a.75.75 0 01.298.062l7.5 3.214a.75.75 0 01.454.69v5.034c0 4.63-2.94 8.75-7.5 10.25a.75.75 0 01-.5 0c-4.56-1.5-7.5-5.62-7.5-10.25V6.216a.75.75 0 01.454-.69l7.5-3.214a.75.75 0 01.294-.062z"
/>
</svg>
);
}
export function TrashIcon() {
return (
<svg {...props}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166M18.16 19.673A2.25 2.25 0 0115.916 21.75H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-11 .397c.34-.059.68-.114 1.022-.165M15.75 5.393v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
);
}
export function RefreshIcon() {
return (
<svg {...props}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992V4.356m-4.992 4.992l3.181-3.03a8.25 8.25 0 00-13.803 3.03M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.03a8.25 8.25 0 0013.803-3.03"
/>
</svg>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import Link from "next/link";
import { ServerWithKeys } from "@/lib/api";
import { Badge, Button, Card, Table, Tbody, Td, Th, Thead, Tr } from "@/components/ui";
import { formatDate } from "../format";
export function AccessTab({ server, onGenerateKey }: { server: ServerWithKeys; onGenerateKey: () => void }) {
const assignments = (server.keys ?? []).filter((a) => a.key);
const active = assignments.filter((a) => !a.revoked_at).length;
return (
<Card padding={false}>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-6 py-4">
<h2 className="flex items-center gap-2 text-lg font-semibold text-text-primary">
Installed SSH keys
<span className="rounded-full bg-surface-2 px-2 py-0.5 font-mono text-[0.68rem] tabular-nums text-text-secondary">{active} active</span>
</h2>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={onGenerateKey}>
Generate key
</Button>
<Link href="/keys">
<Button variant="ghost" size="sm">
Manage keys
</Button>
</Link>
</div>
</div>
{assignments.length === 0 ? (
<div className="py-16 text-center">
<p className="text-sm text-text-secondary">No keys assigned to this server.</p>
<Link href="/keys">
<Button variant="secondary" size="sm" className="mt-3">
Assign a key
</Button>
</Link>
</div>
) : (
<Table>
<Thead>
<Tr>
<Th>Label</Th>
<Th>Fingerprint</Th>
<Th>Source</Th>
<Th>Status</Th>
<Th>Assigned</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{assignments.map((assignment) => (
<Tr key={assignment.key_id}>
<Td label="Label">
<span className="font-medium">{assignment.key.label}</span>
</Td>
<Td label="Fingerprint">
<span className="font-mono text-xs text-text-secondary">{assignment.key.fingerprint}</span>
</Td>
<Td label="Source">
<Badge variant={assignment.key.source === "generated" ? "accent" : "neutral"}>{assignment.key.source}</Badge>
</Td>
<Td label="Status">
<Badge variant={assignment.revoked_at ? "danger" : "success"}>{assignment.revoked_at ? "revoked" : "active"}</Badge>
</Td>
<Td label="Assigned">
<span className="text-xs text-text-secondary">{formatDate(assignment.assigned_at)}</span>
</Td>
<Td>
<Link href={`/keys/${assignment.key_id}`}>
<Button variant="ghost" size="sm">
View
</Button>
</Link>
</Td>
</Tr>
))}
</Tbody>
</Table>
)}
</Card>
);
}
@@ -0,0 +1,171 @@
"use client";
import { useState } from "react";
import { api, ServerWithKeys } from "@/lib/api";
import { Badge, Button, Card, Table, Tbody, Td, Th, Thead, Tr } from "@/components/ui";
/*
* Everything that changes what is installed on the machine: its OS packages,
* its agent, and its existence.
*
* The OS update list is a panel here rather than the modal it used to be. A
* modal made the list a detour off a header button; the work of patching a
* server is the reason this tab exists, so the list is the tab.
*/
export function MaintenanceTab({
server,
latestVersion,
onApplyUpdates,
isApplying,
applySuccess,
onUpdateAgent,
isUpdatingAgent,
updateAgentSuccess,
onDelete,
isDeleting,
}: {
server: ServerWithKeys;
latestVersion?: string;
onApplyUpdates: () => void;
isApplying: boolean;
applySuccess: boolean;
onUpdateAgent: () => void;
isUpdatingAgent: boolean;
updateAgentSuccess: boolean;
onDelete: () => void;
isDeleting: boolean;
}) {
const [copied, setCopied] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const updates = server.available_updates ?? [];
const command = api.getUpdateCommand(server.os_info);
const isWindows = server.os_info?.toLowerCase().includes("windows");
const agentCurrent = !!latestVersion && !!server.agent_version && server.agent_version === latestVersion;
return (
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
<Card padding={false}>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-6 py-4">
<h2 className="text-lg font-semibold text-text-primary">OS updates</h2>
{updates.length > 0 ? <Badge variant="warning">{updates.length} pending</Badge> : <Badge variant="success">up to date</Badge>}
</div>
{updates.length === 0 ? (
<p className="px-6 py-10 text-center text-sm text-text-secondary">No pending package updates. The agent checks hourly.</p>
) : (
<>
{/* Capped so a host with 400 pending packages does not make the
Apply button a scroll away. The count is on the badge. */}
<div className="max-h-80 overflow-y-auto">
<Table>
<Thead>
<Tr>
<Th>Package</Th>
<Th>Current</Th>
<Th>Available</Th>
</Tr>
</Thead>
<Tbody>
{updates.map((u) => (
<Tr key={u.name}>
<Td label="Package">
<span className="font-mono text-sm font-medium">{u.name}</span>
</Td>
<Td label="Current">
<span className="font-mono text-xs text-text-secondary">{u.current_version || "n/a"}</span>
</Td>
<Td label="Available">
<span className="font-mono text-xs text-success">{u.new_version}</span>
</Td>
</Tr>
))}
</Tbody>
</Table>
</div>
<div className="flex flex-wrap items-center gap-3 border-t border-border px-6 py-4">
<Button variant="primary" loading={isApplying} onClick={onApplyUpdates} disabled={server.status !== "active"} title={server.status !== "active" ? "Agent must be online to apply updates" : undefined}>
{applySuccess ? "Sent!" : "Apply updates"}
</Button>
<p className="text-xs text-text-tertiary">Upgrade runs in the background and may take several minutes.</p>
</div>
</>
)}
</Card>
<div className="space-y-6">
<Card padding={false}>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-6 py-4">
<h2 className="text-lg font-semibold text-text-primary">Agent</h2>
{latestVersion && server.agent_version && <Badge variant={agentCurrent ? "success" : "warning"}>{agentCurrent ? "up to date" : "update available"}</Badge>}
</div>
<div className="space-y-4 px-6 py-5">
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm">
<div>
<span className="text-text-secondary">Installed: </span>
<span className="font-mono font-medium text-text-primary">{server.agent_version ? `v${server.agent_version}` : "unknown"}</span>
</div>
<div>
<span className="text-text-secondary">Latest: </span>
<span className="font-mono font-medium text-text-primary">{latestVersion ? `v${latestVersion}` : "n/a"}</span>
</div>
</div>
<div className="relative overflow-x-auto rounded border border-border bg-well px-4 py-2.5 font-mono text-sm">
<span className="text-accent">{isWindows ? "PS>" : "$"}</span> <span className="text-text-primary">{command}</span>
<button
onClick={async () => {
await navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
className="absolute right-2 top-1.5 rounded border border-border bg-surface-2 px-2 py-0.5 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
>
{copied ? <span className="text-success">Copied!</span> : "Copy"}
</button>
</div>
<Button
variant="primary"
loading={isUpdatingAgent}
onClick={onUpdateAgent}
disabled={server.status !== "active"}
title={server.status !== "active" ? "Agent must be online to update" : undefined}
>
{updateAgentSuccess ? "Update sent!" : "Update agent"}
</Button>
</div>
</Card>
<Card padding={false} className="border-danger/30">
<div className="border-b border-danger/30 px-6 py-4">
<h2 className="text-lg font-semibold text-danger">Remove server</h2>
</div>
<div className="space-y-4 px-6 py-5">
<p className="text-sm text-text-secondary">
Deletes this server and its history from Vantage. The agent stays installed on the machine and keeps trying to connect until you uninstall it there.
</p>
{!confirmDelete ? (
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
Remove server
</Button>
) : (
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm text-danger">Remove {server.hostname}?</span>
<Button variant="danger" loading={isDeleting} onClick={onDelete}>
Confirm
</Button>
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
Cancel
</Button>
</div>
)}
</div>
</Card>
</div>
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
"use client";
import { clsx } from "clsx";
import { ServerWithKeys } from "@/lib/api";
import { Card, CardHeader, CardTitle } from "@/components/ui";
import { formatBytes, formatDate } from "../format";
import type { TabId } from "../ServerTabs";
/*
* Overview answers one question: is anything wrong with this machine, and where
* do I go about it. The detail lives on the other tabs — everything here either
* states a fact about the host or points at the tab that can act on it.
*/
export interface Attention {
tone: "danger" | "warning";
title: string;
detail: string;
/** The tab that can do something about it. */
goTo: TabId;
action: string;
}
function StoragePanel({ server }: { server: ServerWithKeys }) {
const partitions = server.inventory?.partitions ?? [];
return (
<Card padding={false}>
<div className="flex items-center justify-between border-b border-border px-6 py-4">
<h2 className="text-lg font-semibold text-text-primary">Storage</h2>
<span className="font-mono text-[0.68rem] uppercase tracking-[0.13em] text-text-secondary">
{partitions.length} partition{partitions.length !== 1 ? "s" : ""}
</span>
</div>
{partitions.length === 0 ? (
<p className="px-6 py-10 text-center text-sm text-text-secondary">No partitions reported. The agent sends a full inventory every 15 minutes.</p>
) : (
<div className="space-y-4 px-6 py-5">
{partitions.map((p) => {
const pct = p.total_bytes > 0 ? (p.used_bytes / p.total_bytes) * 100 : 0;
return (
<div key={p.mountpoint}>
<div className="mb-1.5 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<span className="font-mono text-sm text-text-primary">{p.mountpoint}</span>
<span className="font-mono text-xs text-text-secondary">
{formatBytes(p.used_bytes)} / {formatBytes(p.total_bytes)}
{p.fstype ? ` · ${p.fstype}` : ""}
</span>
</div>
<div className="h-[3px] w-full overflow-hidden rounded-full bg-well">
<div className={clsx("h-full rounded-full", pct >= 90 ? "bg-danger" : pct >= 75 ? "bg-warning" : "bg-accent")} style={{ width: `${Math.min(100, pct)}%` }} />
</div>
</div>
);
})}
</div>
)}
</Card>
);
}
function Fact({ term, children, mono = true }: { term: string; children: React.ReactNode; mono?: boolean }) {
return (
<div className="flex items-baseline justify-between gap-4 border-b border-border-soft py-2.5 last:border-b-0">
<dt className="shrink-0 text-xs text-text-secondary">{term}</dt>
<dd className={clsx("min-w-0 break-all text-right text-sm text-text-primary", mono && "font-mono text-xs")}>{children}</dd>
</div>
);
}
function MachinePanel({ server }: { server: ServerWithKeys }) {
return (
<Card padding={false}>
<div className="border-b border-border px-6 py-4">
<h2 className="text-lg font-semibold text-text-primary">Machine</h2>
</div>
<dl className="px-6 py-2">
<Fact term="OS" mono={false}>
{server.os_info || "unknown"}
</Fact>
{server.inventory?.kernel && <Fact term="Kernel">{server.inventory.kernel}</Fact>}
{server.inventory?.cpu.model && <Fact term="CPU">{server.inventory.cpu.model}</Fact>}
<Fact term="Agent version">{server.agent_version ? `v${server.agent_version}` : "unknown"}</Fact>
<Fact term="Last seen" mono={false}>
{server.last_seen ? formatDate(server.last_seen) : "Never"}
</Fact>
<Fact term="Registered" mono={false}>
{formatDate(server.created_at)}
</Fact>
<Fact term="Server ID">{server.server_id}</Fact>
</dl>
</Card>
);
}
function AttentionPanel({ items, onGoTo }: { items: Attention[]; onGoTo: (tab: TabId) => void }) {
return (
<Card padding={false}>
<div className="border-b border-border px-6 py-4">
<CardHeader className="mb-0">
<CardTitle className="text-lg font-semibold">Needs attention</CardTitle>
</CardHeader>
</div>
{items.length === 0 ? (
<p className="px-6 py-10 text-center text-sm text-success">Nothing outstanding on this server.</p>
) : (
<ul className="divide-y divide-border-soft">
{items.map((item) => (
<li key={item.title} className="flex flex-wrap items-center justify-between gap-3 px-6 py-3.5">
<div className="flex min-w-0 items-start gap-3">
{/* The dot is recognition, never the message — the title says
what is wrong on its own. */}
<span className={clsx("mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full", item.tone === "danger" ? "bg-danger" : "bg-warning")} />
<div className="min-w-0">
<p className="text-sm font-medium text-text-primary">{item.title}</p>
<p className="font-mono text-xs text-text-secondary">{item.detail}</p>
</div>
</div>
<button type="button" onClick={() => onGoTo(item.goTo)} className="text-sm font-semibold text-accent transition-colors hover:text-accent-hover">
{item.action}
</button>
</li>
))}
</ul>
)}
</Card>
);
}
export function OverviewTab({ server, attention, onGoTo }: { server: ServerWithKeys; attention: Attention[]; onGoTo: (tab: TabId) => void }) {
return (
<div className="space-y-6">
<AttentionPanel items={attention} onGoTo={onGoTo} />
{/* Collapses at xl, not lg: the 240px sidebar leaves a 1280px laptop
about 1010px, which is not enough for a two-thirds split. */}
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
<div className="xl:col-span-2">
<StoragePanel server={server} />
</div>
<MachinePanel server={server} />
</div>
</div>
);
}