From 74f6dca2f5e80863a14b83199176b672064690d3 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 27 Jul 2026 22:28:57 +0100 Subject: [PATCH 1/8] feat(web): card-stack tables below sm --- web/components/ui/Table.tsx | 57 +++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/web/components/ui/Table.tsx b/web/components/ui/Table.tsx index d3b8094..df072da 100644 --- a/web/components/ui/Table.tsx +++ b/web/components/ui/Table.tsx @@ -1,11 +1,23 @@ import { clsx } from "clsx"; import { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react"; +/* + * Below sm the table stops being a table: the head is hidden, each row becomes + * a bordered card and each cell becomes a label/value pair. That lives here + * rather than in the six pages that render tables — two markup trees per page + * would drift apart on the first edit, and every one of those trees would mean + * the same thing. + * + * The mobile label uses Th's exact keyed-label idiom (mono, small, widely + * tracked, dimmed) because a key beside a value on a phone is the same device + * as a column head above it on a desktop. + */ + export function Table({ className, children, ...props }: HTMLAttributes) { return (
{children} @@ -16,7 +28,7 @@ export function Table({ className, children, ...props }: HTMLAttributes) { return ( - + {children} ); @@ -24,7 +36,14 @@ export function Thead({ className, children, ...props }: HTMLAttributes) { return ( - + {children} ); @@ -33,7 +52,11 @@ export function Tbody({ className, children, ...props }: HTMLAttributes) { return ( {children} @@ -59,12 +82,34 @@ export function Th({ className, children, ...props }: ThHTMLAttributes) { +interface TdProps extends TdHTMLAttributes { + /** + * The column head this cell belongs to, shown beside the value below sm + * where the real head is hidden. Omit on a trailing action cell — an action + * needs no key, and the button then sits alone on its own row in the card. + */ + label?: string; +} + +export function Td({ className, label, children, ...props }: TdProps) { return ( ); From 60af88525c470459ee4489b6da6b9b2fdd877cfd Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 27 Jul 2026 22:33:16 +0100 Subject: [PATCH 2/8] feat(web): offcanvas sidebar with hamburger below lg --- web/app/(app)/layout.tsx | 11 +----- web/components/AppShell.tsx | 73 ++++++++++++++++++++++++++++++++++ web/components/Sidebar.tsx | 79 +++++++++++++++++++++++++++++++++++-- 3 files changed, 150 insertions(+), 13 deletions(-) create mode 100644 web/components/AppShell.tsx diff --git a/web/app/(app)/layout.tsx b/web/app/(app)/layout.tsx index 5716a50..be7a8f8 100644 --- a/web/app/(app)/layout.tsx +++ b/web/app/(app)/layout.tsx @@ -1,6 +1,5 @@ import { AuthProvider } from "@/components/AuthProvider"; -import { LicenseBanner } from "@/components/LicenseBanner"; -import { Sidebar } from "@/components/Sidebar"; +import { AppShell } from "@/components/AppShell"; export default function AppLayout({ children, @@ -9,13 +8,7 @@ export default function AppLayout({ }) { return ( -
- -
- - {children} -
-
+ {children}
); } diff --git a/web/components/AppShell.tsx b/web/components/AppShell.tsx new file mode 100644 index 0000000..5eaac67 --- /dev/null +++ b/web/components/AppShell.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { usePathname } from "next/navigation"; +import { LicenseBanner } from "@/components/LicenseBanner"; +import { Logo } from "@/components/Logo"; +import { Sidebar, SidebarDrawer } from "@/components/Sidebar"; +import { useAuth } from "@/components/AuthProvider"; + +function MenuIcon() { + return ( + + + + ); +} + +/** + * Owns the responsive chrome so app/(app)/layout.tsx can stay a server + * component. Above lg this is the layout it always was; below lg the sidebar + * becomes an offcanvas behind the top bar's hamburger. + */ +export function AppShell({ children }: { children: React.ReactNode }) { + const [open, setOpen] = useState(false); + const pathname = usePathname(); + const buttonRef = useRef(null); + const { instance } = useAuth(); + + // A drawer that survives navigation would cover the page you just asked for. + useEffect(() => { + setOpen(false); + }, [pathname]); + + function close() { + setOpen(false); + buttonRef.current?.focus(); + } + + return ( +
+ + + +
+
+ + +
+ Vantage + {instance && ( + {instance.name} + )} +
+
+ +
+ + {children} +
+
+
+ ); +} diff --git a/web/components/Sidebar.tsx b/web/components/Sidebar.tsx index 6ab5408..215eefc 100644 --- a/web/components/Sidebar.tsx +++ b/web/components/Sidebar.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useEffect, useRef } from "react"; import { clsx } from "clsx"; import { useAuth } from "@/components/AuthProvider"; import { auth } from "@/lib/api"; @@ -132,7 +133,8 @@ const navItems: NavItem[] = [ { href: "/settings", label: "Settings", icon: , adminOnly: true }, ]; -export function Sidebar() { +/** Shared by the permanent aside and the offcanvas drawer — one copy of the nav. */ +export function SidebarContent({ onNavigate }: { onNavigate?: () => void }) { const pathname = usePathname(); const { user, instance, isAdmin } = useAuth(); @@ -152,8 +154,8 @@ export function Sidebar() { } return ( -
{events.map((e: AuditEvent) => ( - - - - diff --git a/web/app/(app)/keys/[id]/page.tsx b/web/app/(app)/keys/[id]/page.tsx index bf7c6d5..f90a23c 100644 --- a/web/app/(app)/keys/[id]/page.tsx +++ b/web/app/(app)/keys/[id]/page.tsx @@ -385,7 +385,7 @@ export default function KeyDetailPage() { {key.assignments.map((assignment) => ( - - - - - {keys.map((key: Key) => ( - - - - - {incidents.map((inc) => ( - - - diff --git a/web/app/(app)/monitors/page.tsx b/web/app/(app)/monitors/page.tsx index b93fbf4..5c17228 100644 --- a/web/app/(app)/monitors/page.tsx +++ b/web/app/(app)/monitors/page.tsx @@ -76,24 +76,24 @@ export default function MonitorsPage() { {monitors.map((m) => ( - - - - - - - - - + {groups.map((g: SecretGroupSummary) => ( - - - {updates.map((u) => ( - - - @@ -585,19 +585,19 @@ export default function ServerDetailPage() { .filter((a) => a.key) .map((assignment) => ( - - - - - {servers.map((server: Server) => ( - - - - - {runs.map((r: WorkflowRun) => ( - - - - - + + + ))} diff --git a/web/app/(app)/workflows/page.tsx b/web/app/(app)/workflows/page.tsx index 2e347d4..704af38 100644 --- a/web/app/(app)/workflows/page.tsx +++ b/web/app/(app)/workflows/page.tsx @@ -70,15 +70,15 @@ export default function WorkflowsPage() { {workflows.map((w: Workflow) => ( - - - - - - - - +
+ {label && ( + + {label} + + )} {children}
+ {formatDate(e.created_at)} + + {e.actor} + {e.details}
+ + {assignment.server?.ip_address ?? "n/a"} + {assignment.revoked_at ? "revoked" : "active"} + {new Date(assignment.assigned_at).toLocaleDateString()} + {assignment.revoked_at ? new Date(assignment.revoked_at).toLocaleDateString() diff --git a/web/app/(app)/keys/page.tsx b/web/app/(app)/keys/page.tsx index 8f1805c..26cd967 100644 --- a/web/app/(app)/keys/page.tsx +++ b/web/app/(app)/keys/page.tsx @@ -146,21 +146,21 @@ export default function KeysPage() {
+ {key.label} + {key.fingerprint} + {key.source} + {key.assigned_count ?? 0} server{(key.assigned_count ?? 0) !== 1 ? "s" : ""} + {new Date(key.created_at).toLocaleDateString()} diff --git a/web/app/(app)/monitors/[id]/page.tsx b/web/app/(app)/monitors/[id]/page.tsx index f5239ff..43edc76 100644 --- a/web/app/(app)/monitors/[id]/page.tsx +++ b/web/app/(app)/monitors/[id]/page.tsx @@ -180,17 +180,17 @@ export default function MonitorDetailPage() {
+ {new Date(inc.started_at).toLocaleString()} + {inc.resolved_at ? ( {new Date(inc.resolved_at).toLocaleString()} ) : ( ongoing )} + {inc.cause || "n/a"}
+ {m.name} + {m.type} + {targetSummary(m)} + {m.state.status} + {m.state.latency_ms}ms + {m.state.last_check_at ? new Date(m.state.last_check_at).toLocaleTimeString() : "n/a"} diff --git a/web/app/(app)/secrets/[group]/page.tsx b/web/app/(app)/secrets/[group]/page.tsx index aeb23b3..0745b68 100644 --- a/web/app/(app)/secrets/[group]/page.tsx +++ b/web/app/(app)/secrets/[group]/page.tsx @@ -138,11 +138,11 @@ function SecretRow({ group, secret }: { group: string; secret: Secret }) { return (
+ {secret.key} {revealed == null ? •••••••••••• : {revealed}} + {revealed == null ? •••••••••••• : {revealed}} {new Date(secret.updated_at).toLocaleString()} diff --git a/web/app/(app)/secrets/page.tsx b/web/app/(app)/secrets/page.tsx index 85ad4d4..e935803 100644 --- a/web/app/(app)/secrets/page.tsx +++ b/web/app/(app)/secrets/page.tsx @@ -139,15 +139,15 @@ export default function SecretsPage() {
+ {g.group} + {g.key_count} key{g.key_count !== 1 ? "s" : ""} + {new Date(g.updated_at).toLocaleString()} diff --git a/web/app/(app)/servers/[id]/page.tsx b/web/app/(app)/servers/[id]/page.tsx index d886b2f..28b3356 100644 --- a/web/app/(app)/servers/[id]/page.tsx +++ b/web/app/(app)/servers/[id]/page.tsx @@ -269,13 +269,13 @@ function UpdatesModal({ updates, onClose, onApply, isApplying, applySuccess }: {
+ {u.name} + {u.current_version || "n/a"} + {u.new_version}
+ {assignment.key.label} + {assignment.key.fingerprint} + {assignment.key.source} + {assignment.revoked_at ? "revoked" : "active"} + {formatDate(assignment.assigned_at)} diff --git a/web/app/(app)/servers/page.tsx b/web/app/(app)/servers/page.tsx index 3b966de..ab842e1 100644 --- a/web/app/(app)/servers/page.tsx +++ b/web/app/(app)/servers/page.tsx @@ -113,23 +113,23 @@ export default function ServersPage() {
+ {server.hostname} + {server.ip_address} + {server.os_info} + + {server.last_seen ? formatLastSeen(server.last_seen) diff --git a/web/app/(app)/workflows/[id]/runs/page.tsx b/web/app/(app)/workflows/[id]/runs/page.tsx index d5c15b3..43958d6 100644 --- a/web/app/(app)/workflows/[id]/runs/page.tsx +++ b/web/app/(app)/workflows/[id]/runs/page.tsx @@ -51,7 +51,7 @@ export default function WorkflowRunsPage() {
+ + {r.status} {new Date(r.started_at).toLocaleString()}{r.triggered_by}{r.server_runs.length}{new Date(r.started_at).toLocaleString()}{r.triggered_by}{r.server_runs.length}
+ {w.name} + {w.target_server_ids.length} server{w.target_server_ids.length !== 1 ? "s" : ""} + {w.steps.length} diff --git a/web/components/settings/MembersCard.tsx b/web/components/settings/MembersCard.tsx index fd2485c..702b4e4 100644 --- a/web/components/settings/MembersCard.tsx +++ b/web/components/settings/MembersCard.tsx @@ -115,11 +115,11 @@ export function MembersCard() { const locked = isSelf || managedByHQ || (u.role === "owner" && !isOwner); return (
+ {u.email} {isSelf && (you)} + {locked ? ( {u.role} ) : ( @@ -136,11 +136,11 @@ export function MembersCard() { )} + {u.auth_source === "oidc" ? "SSO" : u.auth_source === "hq" ? "Vantage HQ" : "Password"} {u.last_login ? new Date(u.last_login).toLocaleString() : "Never"} + {u.last_login ? new Date(u.last_login).toLocaleString() : "Never"} {managedByHQ ? ( hqUrl ? ( From 5dcc1bf1be58390cf60711b4c00f216f0ce98588 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 27 Jul 2026 22:50:08 +0100 Subject: [PATCH 5/8] feat(web): bottom-sheet modals and collapsing component grids --- web/components/monitors/MonitorForm.tsx | 8 ++++---- web/components/ui/Card.tsx | 2 +- web/components/ui/Modal.tsx | 4 ++-- web/components/workflows/StepPickerModal.tsx | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/web/components/monitors/MonitorForm.tsx b/web/components/monitors/MonitorForm.tsx index 3530f01..6582516 100644 --- a/web/components/monitors/MonitorForm.tsx +++ b/web/components/monitors/MonitorForm.tsx @@ -73,7 +73,7 @@ export function MonitorForm({
-
+
{(["http", "tcp", "icmp", "tls"] as const).map((t) => (
-
+
setHost(e.target.value)} placeholder="example.com" required /> @@ -141,7 +141,7 @@ export function MonitorForm({
)} -
+
setIntervalSec(Number(e.target.value))} min={10} /> diff --git a/web/components/ui/Card.tsx b/web/components/ui/Card.tsx index b08375e..af4ccc4 100644 --- a/web/components/ui/Card.tsx +++ b/web/components/ui/Card.tsx @@ -24,7 +24,7 @@ export function Card({ className, padding = true, children, ...props }: CardProp export function CardHeader({ className, children, ...props }: HTMLAttributes) { return ( -
+
{children}
); diff --git a/web/components/ui/Modal.tsx b/web/components/ui/Modal.tsx index d941b15..d41a686 100644 --- a/web/components/ui/Modal.tsx +++ b/web/components/ui/Modal.tsx @@ -25,10 +25,10 @@ export function Modal({ if (!open) return null; return ( -
+
diff --git a/web/components/workflows/StepPickerModal.tsx b/web/components/workflows/StepPickerModal.tsx index f06f9f7..44d0157 100644 --- a/web/components/workflows/StepPickerModal.tsx +++ b/web/components/workflows/StepPickerModal.tsx @@ -129,7 +129,7 @@ export function StepPickerModal({
{showAdhocCards && ( -
+
-
+
{g.steps.map((s) => ( onSelect(s.step_id)} /> ))} From 5ee3f14eedbcc96235b833c61676b18c1a386ba2 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 27 Jul 2026 22:55:07 +0100 Subject: [PATCH 6/8] feat(web): single-column workflow builder below lg --- web/app/(app)/workflows/[id]/page.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/web/app/(app)/workflows/[id]/page.tsx b/web/app/(app)/workflows/[id]/page.tsx index d35a455..f2b783c 100644 --- a/web/app/(app)/workflows/[id]/page.tsx +++ b/web/app/(app)/workflows/[id]/page.tsx @@ -302,14 +302,14 @@ export default function WorkflowBuilder() { return ( <> -
+
Workflows / {wf.name} · {saving ? "Saving…" : lastSaved ? `Saved ${timeAgo(lastSaved)}` : ""}
-
+
{wf.target_server_ids.length} servers Runs @@ -326,9 +326,9 @@ export default function WorkflowBuilder() { {error &&
{error}
} {notice &&
{notice}
} -
+
{/* CENTER: canvas */} -
+
-
+
{sortedSteps.map((ref, i) => { const lib = libById(ref.step_id); @@ -369,7 +369,7 @@ export default function WorkflowBuilder() { e.dataTransfer.setData("text/plain", JSON.stringify({ kind: "move", from: i })); }} onClick={() => setSelected(i)} - className={`w-[340px] cursor-pointer rounded border bg-surface p-3 ${isSelected ? "border-signal ring-2 ring-signal/40" : "border-border"}`} + className={`w-full cursor-pointer rounded border bg-surface p-3 ${isSelected ? "border-signal ring-2 ring-signal/40" : "border-border"}`} >
{i + 1} @@ -400,7 +400,11 @@ export default function WorkflowBuilder() {
{/* RIGHT: inspector */} -
-
+
+
← {server.hostname} @@ -267,7 +267,7 @@ export default function ServerConsolePage() {
) : ( -
+
diff --git a/web/app/(app)/servers/[id]/page.tsx b/web/app/(app)/servers/[id]/page.tsx index 28b3356..0fff5ee 100644 --- a/web/app/(app)/servers/[id]/page.tsx +++ b/web/app/(app)/servers/[id]/page.tsx @@ -161,7 +161,7 @@ function GenerateKeyModal({ onClose, onSubmit, isPending }: { onClose: () => voi
-
+
{(["ed25519", "rsa", "ecdsa"] as const).map((t) => ( -
+
{server.os_info?.toLowerCase().includes("windows") ? "PS>" : "$"}{" "} {api.getUpdateCommand(server.os_info)}