feat(web): make the control plane usable on mobile
Server Deploy / deploy (push) Successful in 43s

Sidebar becomes a hamburger-driven offcanvas below lg; tables card-stack
below sm via the shared Td primitive's label prop; page padding, modals,
the workflow builder and the remaining fixed layouts all collapse.

Presentation only — no API, route or data-shape changes.

Spec:  docs/superpowers/specs/2026-07-27-web-mobile-responsive-design.md
Plan:  docs/superpowers/plans/2026-07-27-web-mobile-responsive.md
This commit is contained in:
mrhid6
2026-07-27 23:15:25 +01:00
30 changed files with 316 additions and 130 deletions
+5 -5
View File
@@ -49,7 +49,7 @@ export default function AuditPage() {
});
return (
<div className="p-8">
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-text-primary">Audit Log</h1>
<p className="mt-1 text-sm text-text-secondary">
@@ -77,18 +77,18 @@ export default function AuditPage() {
<Tbody>
{events.map((e: AuditEvent) => (
<Tr key={e.id}>
<Td>
<Td label="Time">
<span className="whitespace-nowrap font-mono text-xs text-text-secondary">
{formatDate(e.created_at)}
</span>
</Td>
<Td>
<Td label="Event">
<EventTypeBadge type={e.event_type} />
</Td>
<Td>
<Td label="Actor">
<span className="text-sm text-text-primary">{e.actor}</span>
</Td>
<Td>
<Td label="Details">
<span className="text-sm text-text-secondary">{e.details}</span>
</Td>
</Tr>
+7 -7
View File
@@ -227,7 +227,7 @@ export default function KeyDetailPage() {
if (error || !key) {
return (
<div className="p-8">
<div className="p-4 sm:p-6 lg:p-8">
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">
Key not found or failed to load.
</div>
@@ -239,7 +239,7 @@ export default function KeyDetailPage() {
const assignedServerIds = activeAssignments.map((a) => a.server_id);
return (
<div className="p-8">
<div className="p-4 sm:p-6 lg:p-8">
{showAssign && (
<AssignModal
keyId={keyId}
@@ -385,7 +385,7 @@ export default function KeyDetailPage() {
<Tbody>
{key.assignments.map((assignment) => (
<Tr key={`${assignment.key_id}-${assignment.server_id}`}>
<Td>
<Td label="Server">
<Link
href={`/servers/${assignment.server_id}`}
className="font-medium text-text-primary hover:text-accent"
@@ -393,22 +393,22 @@ export default function KeyDetailPage() {
{assignment.server?.hostname ?? assignment.server_id}
</Link>
</Td>
<Td>
<Td label="IP Address">
<span className="font-mono text-xs text-text-secondary">
{assignment.server?.ip_address ?? "n/a"}
</span>
</Td>
<Td>
<Td label="Status">
<Badge variant={assignment.revoked_at ? "danger" : "success"}>
{assignment.revoked_at ? "revoked" : "active"}
</Badge>
</Td>
<Td>
<Td label="Assigned">
<span className="text-text-secondary text-xs">
{new Date(assignment.assigned_at).toLocaleDateString()}
</span>
</Td>
<Td>
<Td label="Revoked">
<span className="text-text-secondary text-xs">
{assignment.revoked_at
? new Date(assignment.revoked_at).toLocaleDateString()
+7 -7
View File
@@ -106,10 +106,10 @@ export default function KeysPage() {
});
return (
<div className="p-8">
<div className="p-4 sm:p-6 lg:p-8">
{showUpload && <UploadKeyModal onClose={() => setShowUpload(false)} />}
<div className="mb-6 flex items-center justify-between">
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">SSH Keys</h1>
<p className="mt-1 text-sm text-text-secondary">
@@ -146,21 +146,21 @@ export default function KeysPage() {
<Tbody>
{keys.map((key: Key) => (
<Tr key={key.key_id}>
<Td>
<Td label="Label">
<span className="font-medium text-text-primary">{key.label}</span>
</Td>
<Td>
<Td label="Fingerprint">
<span className="font-mono text-xs text-text-secondary">{key.fingerprint}</span>
</Td>
<Td>
<Td label="Source">
<Badge variant={key.source === "generated" ? "accent" : "neutral"}>{key.source}</Badge>
</Td>
<Td>
<Td label="Assignments">
<span className="text-text-secondary">
{key.assigned_count ?? 0} server{(key.assigned_count ?? 0) !== 1 ? "s" : ""}
</span>
</Td>
<Td>
<Td label="Created">
<span className="text-text-secondary text-xs">{new Date(key.created_at).toLocaleDateString()}</span>
</Td>
<Td>
+2 -9
View File
@@ -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 (
<AuthProvider>
<div className="flex h-screen overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-y-auto">
<LicenseBanner />
{children}
</main>
</div>
<AppShell>{children}</AppShell>
</AuthProvider>
);
}
+2 -2
View File
@@ -37,14 +37,14 @@ export default function EditMonitorPage() {
if (!monitor) {
return (
<div className="p-8">
<div className="p-4 sm:p-6 lg:p-8">
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">Monitor not found.</div>
</div>
);
}
return (
<div className="p-8">
<div className="p-4 sm:p-6 lg:p-8">
<Link href={`/monitors/${monitorId}`} className="text-sm text-text-secondary hover:text-text-primary">
{monitor.name}
</Link>
+5 -5
View File
@@ -86,7 +86,7 @@ export default function MonitorDetailPage() {
if (!monitor) {
return (
<div className="p-8">
<div className="p-4 sm:p-6 lg:p-8">
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">Monitor not found.</div>
</div>
);
@@ -96,7 +96,7 @@ export default function MonitorDetailPage() {
const last24 = all.slice(-24);
return (
<div className="p-8">
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6 flex items-start justify-between">
<div>
<Link href="/monitors" className="text-sm text-text-secondary hover:text-text-primary">
@@ -180,17 +180,17 @@ export default function MonitorDetailPage() {
<Tbody>
{incidents.map((inc) => (
<Tr key={inc.incident_id}>
<Td>
<Td label="Started">
<span className="text-xs text-text-secondary">{new Date(inc.started_at).toLocaleString()}</span>
</Td>
<Td>
<Td label="Resolved">
{inc.resolved_at ? (
<span className="text-xs text-text-secondary">{new Date(inc.resolved_at).toLocaleString()}</span>
) : (
<Badge variant="danger">ongoing</Badge>
)}
</Td>
<Td>
<Td label="Cause">
<span className="text-xs text-text-primary">{inc.cause || "n/a"}</span>
</Td>
</Tr>
+1 -1
View File
@@ -20,7 +20,7 @@ export default function NewMonitorPage() {
});
return (
<div className="p-8">
<div className="p-4 sm:p-6 lg:p-8">
<Link href="/monitors" className="text-sm text-text-secondary hover:text-text-primary">
Monitors
</Link>
+8 -8
View File
@@ -31,8 +31,8 @@ export default function MonitorsPage() {
});
return (
<div className="p-8">
<div className="mb-6 flex items-center justify-between">
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Monitors</h1>
<p className="mt-1 text-sm text-text-secondary">Service uptime and latency checks.</p>
@@ -76,24 +76,24 @@ export default function MonitorsPage() {
<Tbody>
{monitors.map((m) => (
<Tr key={m.monitor_id}>
<Td>
<Td label="Name">
<Link href={`/monitors/${m.monitor_id}`} className="font-medium text-text-primary hover:text-accent">
{m.name}
</Link>
</Td>
<Td>
<Td label="Type">
<Badge variant="neutral">{m.type}</Badge>
</Td>
<Td>
<Td label="Target">
<span className="font-mono text-xs text-text-secondary">{targetSummary(m)}</span>
</Td>
<Td>
<Td label="Status">
<Badge variant={statusVariant(m.state.status)}>{m.state.status}</Badge>
</Td>
<Td>
<Td label="Latency">
<span className="text-sm text-text-secondary">{m.state.latency_ms}ms</span>
</Td>
<Td>
<Td label="Last check">
<span className="text-xs text-text-secondary">
{m.state.last_check_at ? new Date(m.state.last_check_at).toLocaleTimeString() : "n/a"}
</span>
+5 -5
View File
@@ -138,11 +138,11 @@ function SecretRow({ group, secret }: { group: string; secret: Secret }) {
return (
<Tr>
<Td>
<Td label="Key">
<span className="font-mono font-medium text-text-primary">{secret.key}</span>
</Td>
<Td>{revealed == null ? <span className="font-mono text-text-tertiary"></span> : <span className="font-mono text-xs break-all text-text-primary">{revealed}</span>}</Td>
<Td>
<Td label="Value">{revealed == null ? <span className="font-mono text-text-tertiary"></span> : <span className="font-mono text-xs break-all text-text-primary">{revealed}</span>}</Td>
<Td label="Updated">
<span className="text-text-secondary text-xs">{new Date(secret.updated_at).toLocaleString()}</span>
</Td>
<Td>
@@ -241,14 +241,14 @@ export default function SecretGroupPage() {
});
return (
<div className="p-8">
<div className="p-4 sm:p-6 lg:p-8">
{showYaml && <YamlModal group={group} onClose={() => setShowYaml(false)} />}
<Link href="/secrets" className="mb-4 inline-flex items-center gap-1 text-sm text-text-secondary hover:text-text-primary">
Back to secrets
</Link>
<div className="mb-6 flex items-center justify-between">
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="font-mono text-2xl font-bold text-text-primary">{group}</h1>
<p className="mt-1 text-sm text-text-secondary">
+6 -6
View File
@@ -50,7 +50,7 @@ function NewGroupModal({ onClose }: { onClose: () => void }) {
className={`${inputClass} font-mono`}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">First key</label>
<input
@@ -99,10 +99,10 @@ export default function SecretsPage() {
});
return (
<div className="p-8">
<div className="p-4 sm:p-6 lg:p-8">
{showNew && <NewGroupModal onClose={() => setShowNew(false)} />}
<div className="mb-6 flex items-center justify-between">
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Secrets</h1>
<p className="mt-1 text-sm text-text-secondary">
@@ -139,15 +139,15 @@ export default function SecretsPage() {
<Tbody>
{groups.map((g: SecretGroupSummary) => (
<Tr key={g.group}>
<Td>
<Td label="Group">
<span className="font-mono font-medium text-text-primary">{g.group}</span>
</Td>
<Td>
<Td label="Keys">
<span className="text-text-secondary">
{g.key_count} key{g.key_count !== 1 ? "s" : ""}
</span>
</Td>
<Td>
<Td label="Last Updated">
<span className="text-text-secondary text-xs">
{new Date(g.updated_at).toLocaleString()}
</span>
+3 -3
View File
@@ -158,14 +158,14 @@ export default function ServerConsolePage() {
if (!server) {
return (
<div className="p-8">
<div className="p-4 sm:p-6 lg:p-8">
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">Server not found or failed to load.</div>
</div>
);
}
return (
<div className="flex h-full flex-col p-8">
<div className="flex h-full flex-col p-4 sm:p-6 lg:p-8">
<div className="mb-4 flex items-center gap-3">
<Link href={`/servers/${serverId}`} className="text-text-secondary hover:text-text-primary text-sm">
{server.hostname}
@@ -267,7 +267,7 @@ export default function ServerConsolePage() {
</div>
</Card>
) : (
<div className="mb-4 flex items-center gap-3">
<div className="mb-4 flex flex-wrap items-center gap-3">
<Button variant="danger" onClick={handleDisconnect}>
Disconnect
</Button>
+12 -12
View File
@@ -161,7 +161,7 @@ function GenerateKeyModal({ onClose, onSubmit, isPending }: { onClose: () => voi
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Type</label>
<div className="grid grid-cols-3 gap-2">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{(["ed25519", "rsa", "ecdsa"] as const).map((t) => (
<button
key={t}
@@ -269,13 +269,13 @@ function UpdatesModal({ updates, onClose, onApply, isApplying, applySuccess }: {
<Tbody>
{updates.map((u) => (
<Tr key={u.name}>
<Td>
<Td label="Package">
<span className="font-medium font-mono text-sm">{u.name}</span>
</Td>
<Td>
<Td label="Current">
<span className="font-mono text-xs text-text-secondary">{u.current_version || "n/a"}</span>
</Td>
<Td>
<Td label="Available">
<span className="font-mono text-xs text-success">{u.new_version}</span>
</Td>
</Tr>
@@ -372,14 +372,14 @@ export default function ServerDetailPage() {
if (error || !server) {
return (
<div className="p-8">
<div className="p-4 sm:p-6 lg:p-8">
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">Server not found or failed to load.</div>
</div>
);
}
return (
<div className="p-8">
<div className="p-4 sm:p-6 lg:p-8">
{showGenerateModal && <GenerateKeyModal onClose={() => setShowGenerateModal(false)} onSubmit={(opts) => generateKey(opts)} isPending={isGenerating} />}
{showUpdatesModal && server.available_updates && (
<UpdatesModal updates={server.available_updates} onClose={() => setShowUpdatesModal(false)} onApply={() => applyUpdates()} isApplying={isApplying} applySuccess={applySuccess} />
@@ -492,7 +492,7 @@ export default function ServerDetailPage() {
>
{updateSuccess ? "Update Sent!" : "Update Agent"}
</Button>
<div className="relative flex-1 min-w-64 rounded-lg border border-border bg-well px-4 py-2.5 font-mono text-sm">
<div className="relative flex-1 min-w-0 overflow-x-auto rounded-lg border border-border bg-well px-4 py-2.5 font-mono text-sm">
<span className="text-accent">{server.os_info?.toLowerCase().includes("windows") ? "PS>" : "$"}</span>{" "}
<span className="text-text-primary">{api.getUpdateCommand(server.os_info)}</span>
<button
@@ -585,19 +585,19 @@ export default function ServerDetailPage() {
.filter((a) => a.key)
.map((assignment) => (
<Tr key={assignment.key_id}>
<Td>
<Td label="Label">
<span className="font-medium">{assignment.key.label}</span>
</Td>
<Td>
<Td label="Fingerprint">
<span className="font-mono text-xs text-text-secondary">{assignment.key.fingerprint}</span>
</Td>
<Td>
<Td label="Source">
<Badge variant={assignment.key.source === "generated" ? "accent" : "neutral"}>{assignment.key.source}</Badge>
</Td>
<Td>
<Td label="Status">
<Badge variant={assignment.revoked_at ? "danger" : "success"}>{assignment.revoked_at ? "revoked" : "active"}</Badge>
</Td>
<Td>
<Td label="Assigned">
<span className="text-text-secondary text-xs">{formatDate(assignment.assigned_at)}</span>
</Td>
<Td>
+1 -1
View File
@@ -25,7 +25,7 @@ export default function NewServerPage() {
};
return (
<div className="p-8">
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-text-primary">Add Server</h1>
<p className="mt-1 text-sm text-text-secondary">
+7 -7
View File
@@ -71,8 +71,8 @@ export default function ServersPage() {
const latestVersion = latestVersionData?.version;
return (
<div className="p-8">
<div className="mb-6 flex items-center justify-between">
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Servers</h1>
<p className="mt-1 text-sm text-text-secondary">
@@ -113,23 +113,23 @@ export default function ServersPage() {
<Tbody>
{servers.map((server: Server) => (
<Tr key={server.server_id}>
<Td>
<Td label="Hostname">
<span className="font-medium text-text-primary">
{server.hostname}
</span>
</Td>
<Td>
<Td label="IP Address">
<span className="font-mono text-text-secondary">
{server.ip_address}
</span>
</Td>
<Td>
<Td label="OS">
<span className="text-text-secondary">{server.os_info}</span>
</Td>
<Td>
<Td label="Status">
<StatusDot status={resolveStatus(server, latestVersion)} />
</Td>
<Td>
<Td label="Last Seen">
<span className="text-text-secondary">
{server.last_seen
? formatLastSeen(server.last_seen)
+2 -2
View File
@@ -237,7 +237,7 @@ export default function LicensePage() {
if (!license) {
return (
<div className="p-8">
<div className="p-4 sm:p-6 lg:p-8">
<Card className="max-w-lg">
<h1 className="text-base font-bold text-text-primary">Licence unavailable</h1>
<p className="mt-1 text-sm text-text-secondary">The licence state could not be read. Reload the page, and check the server logs if it keeps failing.</p>
@@ -253,7 +253,7 @@ export default function LicensePage() {
const hqUrl = process.env.NEXT_PUBLIC_HQ_URL ?? "";
return (
<div className="p-8">
<div className="p-4 sm:p-6 lg:p-8">
<div className="mx-auto max-w-5xl space-y-10">
<div>
<h1 className="text-2xl font-extrabold tracking-[-0.03em] text-text-primary">Licence</h1>
@@ -149,8 +149,8 @@ export default function NotificationSettingsPage() {
const { data: channels, isLoading } = useQuery({ queryKey: ["channels"], queryFn: () => api.listChannels() });
return (
<div className="p-8">
<div className="mb-6 flex items-center justify-between">
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<Link href="/monitors" className="text-sm text-text-secondary hover:text-text-primary">
Monitors
+2 -2
View File
@@ -160,7 +160,7 @@ export default function SettingsPage() {
if (!isAdmin) {
return (
<div className="p-8">
<div className="p-4 sm:p-6 lg:p-8">
<Card className="max-w-lg">
<h1 className="text-base font-bold text-text-primary">You don&apos;t have access</h1>
<p className="mt-1 text-sm text-text-secondary">Settings are available to owners and admins only. Ask an administrator if you need access.</p>
@@ -178,7 +178,7 @@ export default function SettingsPage() {
}
return (
<div className="p-8">
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-8">
<h1 className="text-2xl font-extrabold tracking-[-0.03em] text-text-primary">Settings</h1>
<p className="mt-1 text-sm text-text-secondary">
+1 -1
View File
@@ -91,7 +91,7 @@ export default function StepsPage() {
};
return (
<div className="p-8">
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6 flex items-center gap-3">
<div>
<h1 className="text-xl font-semibold text-text-primary">Steps</h1>
+12 -8
View File
@@ -130,7 +130,7 @@ export default function WorkflowBuilder() {
}, [lastSaved]);
if (!wf) {
return <div className="p-8 text-text-secondary">Loading</div>;
return <div className="p-4 text-text-secondary sm:p-6 lg:p-8">Loading</div>;
}
const libById = (sid?: string) => (sid ? library?.find((l) => l.step_id === sid) : undefined);
@@ -302,14 +302,14 @@ export default function WorkflowBuilder() {
return (
<>
<div className="flex items-center gap-3 border-b border-border bg-surface px-4 py-3">
<div className="flex flex-wrap items-center gap-3 border-b border-border bg-surface px-4 py-3">
<span className="h-2.5 w-2.5 rounded-full bg-signal" />
<div className="flex items-center gap-1.5 text-sm">
<span className="text-text-secondary">Workflows /</span>
<span className="font-medium text-text-primary">{wf.name}</span>
<span className="text-text-secondary">· {saving ? "Saving…" : lastSaved ? `Saved ${timeAgo(lastSaved)}` : ""}</span>
</div>
<div className="ml-auto flex items-center gap-2">
<div className="ml-auto flex flex-wrap items-center gap-2">
<span className="rounded-full border border-border bg-surface-2 px-3 py-1 text-xs text-text-secondary">{wf.target_server_ids.length} servers</span>
<Link href={`/workflows/${id}/runs`} className="rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary">
Runs
@@ -326,9 +326,9 @@ export default function WorkflowBuilder() {
{error && <div className="border-b border-danger/30 bg-danger/10 px-4 py-2 text-sm text-danger">{error}</div>}
{notice && <div className="border-b border-signal/30 bg-signal/10 px-4 py-2 text-sm text-signal">{notice}</div>}
<div className="grid h-[calc(100vh-53px)] grid-cols-[1fr_320px]">
<div className="flex flex-1 flex-col lg:grid lg:h-[calc(100dvh-53px)] lg:grid-cols-[1fr_320px]">
{/* CENTER: canvas */}
<main className="overflow-auto bg-background bg-[radial-gradient(circle_at_1px_1px,theme(colors.border)_1px,transparent_0)] bg-[length:22px_22px] p-8">
<main className="overflow-auto bg-background bg-[radial-gradient(circle_at_1px_1px,theme(colors.border)_1px,transparent_0)] bg-[length:22px_22px] p-4 sm:p-6 lg:p-8">
<div className="pointer-events-none sticky top-0 z-10 flex justify-center pt-4">
<button
onClick={() => setPickerOpen(true)}
@@ -337,7 +337,7 @@ export default function WorkflowBuilder() {
<span className="text-base leading-none">+</span> Add step
</button>
</div>
<div className="mx-auto flex w-[340px] flex-col items-center">
<div className="mx-auto flex w-full max-w-[340px] flex-col items-center">
<DropZone pos={0} />
{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"}`}
>
<div className="mb-2 flex items-center gap-2">
<span className="grid h-5 w-5 place-items-center rounded border border-border font-mono text-[10px] text-text-secondary">{i + 1}</span>
@@ -400,7 +400,11 @@ export default function WorkflowBuilder() {
</main>
{/* RIGHT: inspector */}
<aside className="overflow-auto border-l border-border bg-surface p-4">
<aside
className={`overflow-auto border-border bg-surface p-4 lg:block lg:border-l ${
selected === null || !selectedRef ? "hidden" : "block max-lg:border-t max-lg:max-h-[60dvh]"
}`}
>
{selected === null || !selectedRef ? (
<p className="text-sm text-text-secondary">Select a step to configure it.</p>
) : (
@@ -326,7 +326,7 @@ export default function RunDetail() {
};
if (isLoading || !run) {
return <div className="p-8 text-text-secondary">Loading</div>;
return <div className="p-4 text-text-secondary sm:p-6 lg:p-8">Loading</div>;
}
const totalSteps = run.server_runs.reduce((n, s) => n + s.steps.length, 0);
@@ -340,7 +340,7 @@ export default function RunDetail() {
const ago = fmtDuration(now - startMs);
return (
<div className="mx-auto max-w-[1180px] p-8 pb-16">
<div className="mx-auto max-w-[1180px] p-4 pb-16 sm:p-6 lg:p-8">
{/* identity bar */}
<div className="flex flex-wrap items-start justify-between gap-6">
<div>
+6 -6
View File
@@ -22,7 +22,7 @@ export default function WorkflowRunsPage() {
const { data: runs, isLoading, error } = useQuery({ queryKey: ["runs", id], queryFn: () => api.listRuns(id) });
return (
<div className="p-8">
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6">
<Link href={`/workflows/${id}`} className="text-sm text-text-secondary hover:text-text-primary">
Back to builder
@@ -51,7 +51,7 @@ export default function WorkflowRunsPage() {
<Tbody>
{runs.map((r: WorkflowRun) => (
<Tr key={r.run_id}>
<Td>
<Td label="Run">
<Link
href={`/workflows/${id}/runs/${r.run_id}`}
className="font-mono text-text-primary hover:text-signal"
@@ -59,12 +59,12 @@ export default function WorkflowRunsPage() {
{r.run_id.slice(0, 8)}
</Link>
</Td>
<Td>
<Td label="Status">
<Badge variant={statusVariant[r.status] ?? "neutral"}>{r.status}</Badge>
</Td>
<Td className="text-text-secondary">{new Date(r.started_at).toLocaleString()}</Td>
<Td className="text-text-secondary">{r.triggered_by}</Td>
<Td className="text-text-secondary">{r.server_runs.length}</Td>
<Td label="Started" className="text-text-secondary">{new Date(r.started_at).toLocaleString()}</Td>
<Td label="By" className="text-text-secondary">{r.triggered_by}</Td>
<Td label="Servers" className="text-text-secondary">{r.server_runs.length}</Td>
</Tr>
))}
</Tbody>
+5 -5
View File
@@ -28,8 +28,8 @@ export default function WorkflowsPage() {
});
return (
<div className="p-8">
<div className="mb-6 flex items-center justify-between">
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Workflows</h1>
<p className="mt-1 text-sm text-text-secondary">
@@ -70,15 +70,15 @@ export default function WorkflowsPage() {
<Tbody>
{workflows.map((w: Workflow) => (
<Tr key={w.workflow_id}>
<Td>
<Td label="Name">
<span className="font-medium text-text-primary">{w.name}</span>
</Td>
<Td>
<Td label="Targets">
<span className="text-text-secondary">
{w.target_server_ids.length} server{w.target_server_ids.length !== 1 ? "s" : ""}
</span>
</Td>
<Td>
<Td label="Steps">
<span className="text-text-secondary">{w.steps.length}</span>
</Td>
<Td>
+73
View File
@@ -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 (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
);
}
/**
* 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<HTMLButtonElement>(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 (
<div className="flex h-screen overflow-hidden">
<Sidebar />
<SidebarDrawer open={open} onClose={close} />
<div className="flex min-w-0 flex-1 flex-col overflow-y-auto">
<header className="sticky top-0 z-40 flex h-14 shrink-0 items-center gap-3 border-b border-border bg-surface px-3 lg:hidden">
<button
ref={buttonRef}
type="button"
onClick={() => setOpen(true)}
aria-label="Open navigation"
aria-expanded={open}
aria-controls="app-sidebar-drawer"
className="-ml-1 rounded p-2 text-text-secondary transition-colors hover:bg-surface-2 hover:text-text-primary"
>
<MenuIcon />
</button>
<Logo className="h-7 w-7 shrink-0 text-logo" />
<div className="min-w-0">
<span className="block text-sm font-extrabold leading-tight tracking-[-0.035em] text-text-primary">Vantage</span>
{instance && (
<span className="block truncate font-mono text-[0.62rem] uppercase tracking-[0.1em] text-text-secondary">{instance.name}</span>
)}
</div>
</header>
<main className="flex min-w-0 flex-1 flex-col">
<LicenseBanner />
{children}
</main>
</div>
</div>
);
}
+75 -4
View File
@@ -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: <SettingsIcon />, 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 (
<aside className="flex h-screen w-60 flex-col border-r border-border bg-surface">
<div className="flex h-16 items-center gap-3 border-b border-border px-5">
<>
<div className="flex h-16 shrink-0 items-center gap-3 border-b border-border px-5">
<Logo className="h-8 w-8 text-logo" />
<div className="min-w-0">
<span className="block text-base font-extrabold leading-tight tracking-[-0.035em] text-text-primary">Vantage</span>
@@ -171,6 +173,7 @@ export function Sidebar() {
<li key={item.href}>
<Link
href={item.href}
onClick={onNavigate}
// The active marker is an accent bar, the same device
// site/ uses to mark the chosen plan. A filled pill
// reads as a button you can press again.
@@ -190,7 +193,7 @@ export function Sidebar() {
</ul>
</nav>
<div className="border-t border-border px-4 py-3">
<div className="shrink-0 border-t border-border px-4 py-3">
{user && (
<div className="mb-3">
<p className="truncate text-sm font-medium text-text-primary">{user.name || user.email}</p>
@@ -209,6 +212,74 @@ export function Sidebar() {
)}
</div>
</div>
</>
);
}
/** The permanent sidebar. Below lg the drawer takes over. */
export function Sidebar() {
return (
<aside className="hidden h-screen w-60 shrink-0 flex-col border-r border-border bg-surface lg:flex">
<SidebarContent />
</aside>
);
}
/**
* The offcanvas below lg. Always mounted so the slide runs in both directions;
* closed it is inert (invisible + pointer-events-none) rather than unmounted.
*/
export function SidebarDrawer({ open, onClose }: { open: boolean; onClose: () => void }) {
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
panelRef.current?.focus();
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = previousOverflow;
};
}, [open, onClose]);
return (
<div
className={clsx(
"fixed inset-0 z-50 lg:hidden",
open ? "visible" : "invisible pointer-events-none",
)}
>
<div
aria-hidden="true"
onClick={onClose}
className={clsx(
"absolute inset-0 bg-black/60 transition-opacity duration-200",
open ? "opacity-100" : "opacity-0",
)}
/>
<div
ref={panelRef}
id="app-sidebar-drawer"
role="dialog"
aria-modal="true"
aria-label="Navigation"
tabIndex={-1}
className={clsx(
"absolute inset-y-0 left-0 flex w-72 max-w-[85%] flex-col border-r border-border bg-surface outline-none transition-transform duration-200 ease-out",
open ? "translate-x-0" : "-translate-x-full",
)}
>
<SidebarContent onNavigate={onClose} />
</div>
</div>
);
}
+4 -4
View File
@@ -73,7 +73,7 @@ export function MonitorForm({
<div>
<label className={labelClass}>Type</label>
<div className="grid grid-cols-4 gap-2">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{(["http", "tcp", "icmp", "tls"] as const).map((t) => (
<button
key={t}
@@ -95,7 +95,7 @@ export function MonitorForm({
<label className={labelClass}>URL</label>
<input className={inputClass} value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://example.com/health" required />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className={labelClass}>Method</label>
<select className={inputClass} value={method} onChange={(e) => setMethod(e.target.value)}>
@@ -120,7 +120,7 @@ export function MonitorForm({
)}
{(type === "tcp" || type === "tls" || type === "icmp") && (
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className={labelClass}>Host</label>
<input className={inputClass} value={host} onChange={(e) => setHost(e.target.value)} placeholder="example.com" required />
@@ -141,7 +141,7 @@ export function MonitorForm({
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className={labelClass}>Interval (seconds)</label>
<input type="number" className={inputClass} value={intervalSec} onChange={(e) => setIntervalSec(Number(e.target.value))} min={10} />
+5 -5
View File
@@ -115,11 +115,11 @@ export function MembersCard() {
const locked = isSelf || managedByHQ || (u.role === "owner" && !isOwner);
return (
<Tr key={u.user_id}>
<Td>
<Td label="Email">
<span className="font-medium">{u.email}</span>
{isSelf && <span className="ml-2 text-xs text-text-tertiary">(you)</span>}
</Td>
<Td>
<Td label="Role">
{locked ? (
<Badge variant={roleVariant(u.role)}>{u.role}</Badge>
) : (
@@ -136,11 +136,11 @@ export function MembersCard() {
</select>
)}
</Td>
<Td>
<Td label="Sign-in">
<Badge variant="neutral">{u.auth_source === "oidc" ? "SSO" : u.auth_source === "hq" ? "Vantage HQ" : "Password"}</Badge>
</Td>
<Td className="text-text-secondary">{u.last_login ? new Date(u.last_login).toLocaleString() : "Never"}</Td>
<Td className="text-right">
<Td label="Last login" className="text-text-secondary">{u.last_login ? new Date(u.last_login).toLocaleString() : "Never"}</Td>
<Td label="Actions" className="text-right">
{managedByHQ ? (
hqUrl ? (
<a href={hqUrl} target="_blank" rel="noreferrer" className="text-xs text-text-secondary underline">
+1 -1
View File
@@ -24,7 +24,7 @@ export function Card({ className, padding = true, children, ...props }: CardProp
export function CardHeader({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div className={clsx("mb-4 flex items-center justify-between", className)} {...props}>
<div className={clsx("mb-4 flex flex-wrap items-center justify-between gap-2", className)} {...props}>
{children}
</div>
);
+2 -2
View File
@@ -25,10 +25,10 @@ export function Modal({
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-4">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div
className={`relative z-10 w-full ${wide ? "max-w-2xl" : "max-w-md"} max-h-[90vh] overflow-auto rounded border border-border bg-surface shadow-panel`}
className={`relative z-10 w-full ${wide ? "sm:max-w-2xl" : "sm:max-w-md"} max-h-[85dvh] overflow-auto rounded rounded-b-none border border-b-0 border-border bg-surface shadow-panel sm:rounded sm:border-b`}
role="dialog"
aria-modal="true"
>
+51 -6
View File
@@ -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<HTMLTableElement>) {
return (
<div className="overflow-x-auto">
<table
className={clsx("w-full border-collapse text-sm", className)}
className={clsx("w-full border-collapse text-sm max-sm:block", className)}
{...props}
>
{children}
@@ -16,7 +28,7 @@ export function Table({ className, children, ...props }: HTMLAttributes<HTMLTabl
export function Thead({ className, children, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return (
<thead className={clsx("border-b border-border", className)} {...props}>
<thead className={clsx("border-b border-border max-sm:hidden", className)} {...props}>
{children}
</thead>
);
@@ -24,7 +36,14 @@ export function Thead({ className, children, ...props }: HTMLAttributes<HTMLTabl
export function Tbody({ className, children, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return (
<tbody className={clsx("divide-y divide-border", className)} {...props}>
<tbody
className={clsx(
"divide-y divide-border",
"max-sm:block max-sm:space-y-3 max-sm:divide-y-0 max-sm:p-3",
className
)}
{...props}
>
{children}
</tbody>
);
@@ -33,7 +52,11 @@ export function Tbody({ className, children, ...props }: HTMLAttributes<HTMLTabl
export function Tr({ className, children, ...props }: HTMLAttributes<HTMLTableRowElement>) {
return (
<tr
className={clsx("transition-colors hover:bg-surface-2/50", className)}
className={clsx(
"transition-colors hover:bg-surface-2/50",
"max-sm:block max-sm:rounded max-sm:border max-sm:border-border max-sm:bg-surface-2/40 max-sm:p-3",
className
)}
{...props}
>
{children}
@@ -59,12 +82,34 @@ export function Th({ className, children, ...props }: ThHTMLAttributes<HTMLTable
);
}
export function Td({ className, children, ...props }: TdHTMLAttributes<HTMLTableCellElement>) {
interface TdProps extends TdHTMLAttributes<HTMLTableCellElement> {
/**
* 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 (
<td
className={clsx("px-4 py-3 text-text-primary", className)}
className={clsx(
"px-4 py-3 text-text-primary",
"max-sm:flex max-sm:items-start max-sm:gap-4 max-sm:px-0 max-sm:py-1.5",
// Exactly one justify class — clsx picks it. Emitting both and relying
// on string order would not work: Tailwind's output order decides which
// of two same-property utilities wins, not the order in this array.
label ? "max-sm:justify-between" : "max-sm:justify-end max-sm:pt-2.5",
className
)}
{...props}
>
{label && (
<span className="hidden font-mono text-[0.68rem] uppercase leading-5 tracking-[0.13em] text-text-secondary max-sm:inline">
{label}
</span>
)}
{children}
</td>
);
+2 -2
View File
@@ -129,7 +129,7 @@ export function StepPickerModal({
</div>
{showAdhocCards && (
<div className="grid grid-cols-2 gap-2.5">
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
<button
onClick={onAddAdhoc}
className="flex min-h-[74px] items-center justify-center gap-2 rounded border border-dashed border-border text-sm text-text-secondary hover:border-signal/55 hover:text-signal"
@@ -165,7 +165,7 @@ export function StepPickerModal({
{g.label}
<span className="h-px flex-1 bg-border" />
</div>
<div className="grid grid-cols-2 gap-2.5">
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
{g.steps.map((s) => (
<StepCard key={s.step_id} step={s} onAdd={() => onSelect(s.step_id)} />
))}