Compare commits

...
10 Commits
Author SHA1 Message Date
mrhid6 14a11886ae 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
2026-07-27 23:15:25 +01:00
mrhid6 4be7d24aec feat(web): collapse remaining fixed layouts on small screens 2026-07-27 23:01:30 +01:00
mrhid6 dd4ce5bb3e fix(web): scope inspector top border to below lg 2026-07-27 22:57:53 +01:00
mrhid6 5ee3f14eed feat(web): single-column workflow builder below lg 2026-07-27 22:55:07 +01:00
mrhid6 5dcc1bf1be feat(web): bottom-sheet modals and collapsing component grids 2026-07-27 22:50:08 +01:00
mrhid6 facef270b7 feat(web): label table cells for the mobile card stack 2026-07-27 22:46:39 +01:00
mrhid6 e8c75e974d feat(web): responsive page padding and stacking page headers 2026-07-27 22:42:22 +01:00
mrhid6 60af88525c feat(web): offcanvas sidebar with hamburger below lg 2026-07-27 22:33:16 +01:00
mrhid6 74f6dca2f5 feat(web): card-stack tables below sm 2026-07-27 22:28:57 +01:00
mrhid6 effd991c31 docs: mobile responsive design spec and implementation plan for web/ 2026-07-27 22:24:26 +01:00
32 changed files with 1421 additions and 130 deletions
@@ -0,0 +1,928 @@
# Control plane mobile responsiveness — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make `web/` (the Vantage control plane UI) usable on a phone — the sidebar becomes a hamburger-driven offcanvas below 1024px, tables become card stacks below 640px, and every fixed desktop layout collapses.
**Architecture:** A new client component `AppShell` owns the responsive chrome so `app/(app)/layout.tsx` stays a server component. `Sidebar.tsx` splits into a shared `SidebarContent` plus two containers (permanent aside, offcanvas drawer) so the nav exists in exactly one copy. The table card-stack lives in the `ui/Table.tsx` primitives via Tailwind `max-sm:` variants, so pages keep one markup tree and opt in with a `label` prop per cell.
**Tech Stack:** Next.js 16 (App Router), React 18, Tailwind 3.4, `clsx`. **No new dependencies.**
## Global Constraints
- **Scope is `web/` only.** Do not touch `site/`, `adminsite/`, `server/`, `admin/` or any Go code.
- **No hex colours anywhere.** Tailwind maps `var(--…)` tokens only. Use `bg-surface`, `border-border`, `text-text-secondary` etc. A literal `#` in a class is a defect. (`bg-black/60` is the one existing exception, already used by `Modal.tsx` for its backdrop — reuse it, do not introduce others.)
- **Breakpoints:** sidebar collapses below `lg` (1024px). Tables card-stack below `sm` (640px). Do not invent other breakpoints.
- **No new dependencies.** No headless-ui, no framer-motion.
- **Presentation only.** No API, route, query-key or data-shape changes.
- **Radius:** `rounded`, `rounded-lg`, `rounded-md` and `rounded-xl` all resolve to 46px via `tailwind.config.ts`. Prefer `rounded` in new code.
- Use `dvh`, not `vh`, for any new viewport-height value — mobile browser chrome makes `vh` overshoot.
- Indentation follows the file you are editing. `web/` is mixed: some files use 4 spaces (`Sidebar.tsx`, `keys/page.tsx`), others 2 (`servers/page.tsx`, `ui/*`). Match the file, do not reformat it.
- **There is no test framework in this repo.** No jest, no vitest, no playwright. Verification is `npx next lint`, `npx next build`, and targeted `grep` audits. Do not add a test framework.
- Run all commands from `d:\Development\Websites\vantage\web`.
---
### Task 1: Responsive table primitives
The card stack goes in the primitives, not the pages. Six pages render tables; giving each one a second markup tree would double the markup and drift on the first edit.
**Files:**
- Modify: `web/components/ui/Table.tsx` (whole file)
**Interfaces:**
- Consumes: nothing.
- Produces: `Td` gains an optional prop `label?: string`. Below `sm`, a `Td` with a `label` renders `<span>{label}</span>` before its children; a `Td` without one renders children alone, right-aligned. `Table`, `Thead`, `Tbody`, `Tr`, `Th` keep their existing signatures. Task 4 consumes `label`.
- [ ] **Step 1: Rewrite `web/components/ui/Table.tsx`**
Replace the entire file with:
```tsx
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 max-sm:block", className)}
{...props}
>
{children}
</table>
</div>
);
}
export function Thead({ className, children, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return (
<thead className={clsx("border-b border-border max-sm:hidden", className)} {...props}>
{children}
</thead>
);
}
export function Tbody({ className, children, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return (
<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>
);
}
export function Tr({ className, children, ...props }: HTMLAttributes<HTMLTableRowElement>) {
return (
<tr
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}
</tr>
);
}
export function Th({ className, children, ...props }: ThHTMLAttributes<HTMLTableCellElement>) {
return (
<th
className={clsx(
// site/'s keyed-label idiom: mono, small, widely tracked, dimmed.
// A column head is a key, not prose.
// text-secondary, not tertiary: a column head is how you navigate the
// table, and tertiary lands under 4.5:1 at this size.
"px-4 py-3 text-left font-mono text-[0.68rem] uppercase tracking-[0.13em] text-text-secondary",
className
)}
{...props}
>
{children}
</th>
);
}
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",
"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>
);
}
```
- [ ] **Step 2: Verify it compiles and lints**
```bash
npx tsc --noEmit
npx next lint
```
Expected: both clean. `tsc` may take ~30s. If `tsc --noEmit` errors on pre-existing issues unrelated to `Table.tsx`, note them and move on — only new errors matter.
- [ ] **Step 3: Commit**
```bash
git add web/components/ui/Table.tsx
git commit -m "feat(web): card-stack tables below sm"
```
---
### Task 2: Offcanvas sidebar
**Files:**
- Modify: `web/components/Sidebar.tsx` (whole file)
- Create: `web/components/AppShell.tsx`
- Modify: `web/app/(app)/layout.tsx` (whole file)
**Interfaces:**
- Consumes: `useAuth()` from `@/components/AuthProvider` returning `{ user, instance, isAdmin }`; `auth.logout()` from `@/lib/api`; `Logo` from `@/components/Logo`.
- Produces:
- `Sidebar.tsx` exports `SidebarContent({ onNavigate }: { onNavigate?: () => void })`, `Sidebar()` (permanent aside) and `SidebarDrawer({ open, onClose }: { open: boolean; onClose: () => void })`.
- `AppShell.tsx` exports `AppShell({ children }: { children: React.ReactNode })`.
- No later task depends on these names.
- [ ] **Step 1: Rewrite `web/components/Sidebar.tsx`**
Keep every icon component and the `navItems` array **exactly as they are** — do not retype the SVG path data, it is long and easy to corrupt. Change only from `export function Sidebar()` (line 135) to the end of the file, replacing it with the following. The file uses 4-space indentation.
```tsx
/** 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();
const visibleItems = navItems.filter((item) => !item.adminOnly || isAdmin);
const activeHref = visibleItems.reduce<string | null>((best, item) => {
const matches = pathname === item.href || pathname.startsWith(item.href + "/");
if (!matches) return best;
return best === null || item.href.length > best.length ? item.href : best;
}, null);
async function handleLogout() {
try {
await auth.logout();
} catch {}
window.location.href = "/login";
}
return (
<>
<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>
{instance && (
<span className="block truncate font-mono text-[0.68rem] uppercase tracking-[0.1em] text-text-secondary">{instance.name}</span>
)}
</div>
</div>
<nav className="flex-1 overflow-y-auto px-3 py-4">
<ul className="space-y-1">
{visibleItems.map((item) => {
const isActive = activeHref === item.href;
return (
<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.
className={clsx(
"relative flex items-center gap-3 rounded px-3 py-2.5 text-sm transition-colors",
isActive
? "bg-surface-2 font-semibold text-text-primary before:absolute before:inset-y-1 before:left-0 before:w-[2px] before:rounded-full before:bg-accent before:content-['']"
: "font-medium text-text-secondary hover:bg-surface-2 hover:text-text-primary",
)}
>
{item.icon}
{item.label}
</Link>
</li>
);
})}
</ul>
</nav>
<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>
<p className="truncate text-xs text-text-secondary">
{user.email}
{user.role && <span className="ml-1 text-text-tertiary">· {user.role}</span>}
</p>
</div>
)}
<div className="flex items-center justify-between">
<p className="font-mono text-[0.68rem] uppercase tracking-[0.1em] text-text-secondary">Vantage v1.0</p>
{user && (
<button type="button" onClick={handleLogout} className="text-xs text-text-secondary transition-colors hover:text-danger">
Logout
</button>
)}
</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>
);
}
```
Then update the import line at the top of the file (currently line 4) so `useEffect` and `useRef` are available:
```tsx
import { usePathname } from "next/navigation";
import { useEffect, useRef } from "react";
```
- [ ] **Step 2: Create `web/components/AppShell.tsx`**
```tsx
"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>
);
}
```
- [ ] **Step 3: Rewrite `web/app/(app)/layout.tsx`**
```tsx
import { AuthProvider } from "@/components/AuthProvider";
import { AppShell } from "@/components/AppShell";
export default function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<AuthProvider>
<AppShell>{children}</AppShell>
</AuthProvider>
);
}
```
`LicenseBanner` and `Sidebar` are no longer imported here — `AppShell` renders both.
- [ ] **Step 4: Verify**
```bash
npx tsc --noEmit
npx next lint
npx next build
```
Expected: all three succeed. `next build` is the one that matters — it catches a client component imported into a server component boundary.
- [ ] **Step 5: Sanity-check the scroll container**
Read `web/app/(app)/servers/[id]/console/page.tsx` around line 153 and 168. It uses `h-full`, which now resolves against `<main class="flex min-w-0 flex-1 flex-col">` rather than the old `<main class="flex-1 overflow-y-auto">`. Confirm the console page still has a height to fill; if `h-full` no longer resolves, change those two wrappers to `flex-1` instead. Task 7 revisits this file, so a note is acceptable here if you prefer to fix it there — but write the note down.
- [ ] **Step 6: Commit**
```bash
git add web/components/Sidebar.tsx web/components/AppShell.tsx "web/app/(app)/layout.tsx"
git commit -m "feat(web): offcanvas sidebar with hamburger below lg"
```
---
### Task 3: Page padding and header rows
**Files:**
- Modify: all 21 files under `web/app` and `web/components` containing `p-8`
- Modify: the title-plus-action header rows listed below
**Interfaces:**
- Consumes: nothing. Produces: nothing. Pure class edits.
- [ ] **Step 1: List every occurrence**
```bash
cd web && grep -rn "p-8" app components
```
Expected: 30 occurrences across 21 files.
- [ ] **Step 2: Replace each page-level `p-8` with `p-4 sm:p-6 lg:p-8`**
Apply to every occurrence **except** these two, which Task 6 and Task 7 handle and which need different values:
- `app/(app)/workflows/[id]/page.tsx:331` (the canvas `<main>`) — leave for Task 6.
- `app/(app)/servers/[id]/console/page.tsx:168` — leave for Task 7.
The inline loading states (`<div className="p-8 text-text-secondary">Loading…</div>`) get the same treatment: `className="p-4 text-text-secondary sm:p-6 lg:p-8"`.
Do this file by file with `Edit`. A blind `sed` would also hit `p-8` inside strings or unrelated contexts — check each match.
- [ ] **Step 3: Make title-plus-action header rows stack**
In each of these, change `className="mb-6 flex items-center justify-between"` to
`className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"`:
- `app/(app)/servers/page.tsx:75`
- `app/(app)/keys/page.tsx:112`
- `app/(app)/monitors/page.tsx:35`
- `app/(app)/workflows/page.tsx:32`
- `app/(app)/secrets/page.tsx:105`
- `app/(app)/secrets/[group]/page.tsx:251`
- `app/(app)/settings/notifications/page.tsx:153`
Leave `flex items-center justify-between` rows that are *inside* a card header or a table cell — those hold two small items and are fine at 390px. Only the page-top title/action rows change.
- [ ] **Step 4: Verify no unprefixed `p-8` survives**
```bash
cd web && grep -rn 'className="[^"]*\bp-8\b' app components | grep -v "sm:p-8\|lg:p-8"
```
Expected: exactly two lines — the two deferred to Tasks 6 and 7.
- [ ] **Step 5: Verify**
```bash
npx next lint && npx next build
```
Expected: both succeed.
- [ ] **Step 6: Commit**
```bash
git add web/app web/components
git commit -m "feat(web): responsive page padding and stacking page headers"
```
---
### Task 4: Label every table cell
**Files:**
- Modify: `web/app/(app)/servers/page.tsx:116-145`
- Modify: `web/app/(app)/keys/page.tsx:149-169`
- Modify: `web/app/(app)/monitors/page.tsx:79-98`
- Modify: `web/app/(app)/secrets/page.tsx:142-157`
- Modify: `web/app/(app)/secrets/[group]/page.tsx:141-150`
- Modify: `web/app/(app)/workflows/page.tsx:73-86`
- Modify: `web/app/(app)/workflows/[id]/runs/page.tsx:54-68`
- Modify: `web/app/(app)/audit/page.tsx:80-93`
- Modify: `web/app/(app)/keys/[id]/page.tsx:388-420`
- Modify: `web/app/(app)/servers/[id]/page.tsx:272-280` and `:588-605`
- Modify: `web/app/(app)/monitors/[id]/page.tsx:183-195`
- Modify: `web/components/settings/MembersCard.tsx:118-145`
**Interfaces:**
- Consumes: `Td`'s `label?: string` prop from Task 1.
- Produces: nothing.
- [ ] **Step 1: Add `label` to each `Td`, matching its `Th`**
For every table, the Nth `<Td>` in a `<Tr>` takes the text of the Nth `<Th>`. Where the `Th` is empty (`<Th />` — the trailing action column), the matching `Td` gets **no** `label`.
The mapping, `Th` order per file:
| File | Column labels, in order |
| --- | --- |
| `servers/page.tsx` | Hostname · IP Address · OS · Status · Last Seen · *(none)* |
| `keys/page.tsx` | Label · Fingerprint · Source · Assignments · Created · *(none)* |
| `monitors/page.tsx` | Name · Type · Target · Status · Latency · Last check |
| `secrets/page.tsx` | Group · Keys · Last Updated · *(none)* |
| `secrets/[group]/page.tsx` | Key · Value · Updated · *(none)* |
| `workflows/page.tsx` | Name · Targets · Steps · *(none)* |
| `workflows/[id]/runs/page.tsx` | Run · Status · Started · By · Servers |
| `audit/page.tsx` | Time · Event · Actor · Details |
| `keys/[id]/page.tsx` | Server · IP Address · Status · Assigned · Revoked · *(none)* |
| `servers/[id]/page.tsx` (updates table) | Package · Current · Available |
| `servers/[id]/page.tsx` (keys table) | Label · Fingerprint · Source · Status · Assigned · *(none)* |
| `monitors/[id]/page.tsx` | Started · Resolved · Cause |
| `MembersCard.tsx` | Email · Role · Sign-in · Last login · Actions |
Worked example — `servers/page.tsx` lines 116145 become:
```tsx
<Td label="Hostname">
<span className="font-medium text-text-primary">
{server.hostname}
</span>
</Td>
<Td label="IP Address">
<span className="font-mono text-text-secondary">
{server.ip_address}
</span>
</Td>
<Td label="OS">
<span className="text-text-secondary">{server.os_info}</span>
</Td>
<Td label="Status">
<StatusDot status={resolveStatus(server, latestVersion)} />
</Td>
<Td label="Last Seen">
<span className="text-text-secondary">
{server.last_seen
? formatLastSeen(server.last_seen)
: "Never"}
</span>
</Td>
<Td>
<Link href={`/servers/${server.server_id}`}>
<Button variant="ghost" size="sm">
View
</Button>
</Link>
</Td>
```
Note the last `Td` is unchanged — no `label`, so the "View →" button sits alone on its own row at the bottom of the card.
Second worked example — `MembersCard.tsx` line 142143, where `Td` already carries a `className`. Both props coexist:
```tsx
<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">
```
`MembersCard`'s last column has a real `Th` ("Actions"), so unlike the others it **does** take a label.
- [ ] **Step 2: Verify no `Td` was missed**
```bash
cd web && grep -rn "<Td" app components | grep -v "label="
```
Expected: only the trailing action cells listed as *(none)* above — 7 of them (`servers`, `keys`, `secrets`, `secrets/[group]`, `workflows`, `keys/[id]`, `servers/[id]` keys table). Any other bare `<Td` is a miss.
- [ ] **Step 3: Verify**
```bash
npx next lint && npx next build
```
Expected: both succeed.
- [ ] **Step 4: Commit**
```bash
git add web/app web/components
git commit -m "feat(web): label table cells for the mobile card stack"
```
---
### Task 5: Modal bottom sheet and shared-component grids
**Files:**
- Modify: `web/components/ui/Modal.tsx:28-31`
- Modify: `web/components/monitors/MonitorForm.tsx:76,98,123,144`
- Modify: `web/components/workflows/StepPickerModal.tsx:132,168`
- Modify: `web/components/ui/Card.tsx:27`
**Interfaces:**
- Consumes: nothing. Produces: nothing.
- [ ] **Step 1: Make `Modal` a bottom sheet below `sm`**
In `web/components/ui/Modal.tsx`, replace lines 2834:
```tsx
<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 ? "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"
>
```
The `max-w-*` gains an `sm:` prefix so the sheet is full-width on a phone. `dvh` rather than `vh` because mobile browser chrome makes `vh` overshoot.
- [ ] **Step 2: Collapse the grids in `MonitorForm.tsx`**
- Line 76: `grid grid-cols-4 gap-2``grid grid-cols-2 gap-2 sm:grid-cols-4`
- Lines 98, 123, 144: `grid grid-cols-2 gap-4``grid grid-cols-1 gap-4 sm:grid-cols-2`
- [ ] **Step 3: Collapse the grids in `StepPickerModal.tsx`**
Lines 132 and 168: `grid grid-cols-2 gap-2.5``grid grid-cols-1 gap-2.5 sm:grid-cols-2`
- [ ] **Step 4: Let `CardHeader` wrap**
`web/components/ui/Card.tsx` line 27: `"mb-4 flex items-center justify-between"``"mb-4 flex flex-wrap items-center justify-between gap-2"`. Card headers hold a title and an action; at 390px they need to be allowed to wrap rather than crush the title.
- [ ] **Step 5: Verify**
```bash
npx next lint && npx next build
```
Expected: both succeed.
- [ ] **Step 6: Commit**
```bash
git add web/components/ui/Modal.tsx web/components/ui/Card.tsx web/components/monitors/MonitorForm.tsx web/components/workflows/StepPickerModal.tsx
git commit -m "feat(web): bottom-sheet modals and collapsing component grids"
```
---
### Task 6: Workflow builder
**Files:**
- Modify: `web/app/(app)/workflows/[id]/page.tsx:305-324` (header), `:329` (grid), `:331` (canvas), `:340` (column), `:372` (node), `:403` (inspector)
**Interfaces:**
- Consumes: nothing. Produces: nothing.
Below `lg` the fixed-height two-column grid is dropped entirely: single column, natural page flow. The `100dvh` arithmetic only makes sense at `lg`, where there is no mobile top bar above it.
- [ ] **Step 1: Let the header wrap (line 305)**
```tsx
<div className="flex flex-wrap items-center gap-3 border-b border-border bg-surface px-4 py-3">
```
and on line 312 change `className="ml-auto flex items-center gap-2"` to
`className="ml-auto flex flex-wrap items-center gap-2"`.
- [ ] **Step 2: Make the shell single-column below lg (line 329)**
```tsx
<div className="flex flex-1 flex-col lg:grid lg:h-[calc(100dvh-53px)] lg:grid-cols-[1fr_320px]">
```
`h-[calc(100vh-53px)]` becomes `lg:h-[calc(100dvh-53px)]``lg:` because the mobile top bar changes the arithmetic, and `dvh` because `vh` overshoots on mobile.
- [ ] **Step 3: Canvas padding (line 331)**
```tsx
<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">
```
- [ ] **Step 4: Let the node column and nodes be fluid (lines 340 and 372)**
Line 340:
```tsx
<div className="mx-auto flex w-full max-w-[340px] flex-col items-center">
```
Line 372 — the node itself. The wrapping `<div key={wfIdx} className="w-full">` on line 349 already constrains it, so the node just fills:
```tsx
className={`w-full cursor-pointer rounded border bg-surface p-3 ${isSelected ? "border-signal ring-2 ring-signal/40" : "border-border"}`}
```
- [ ] **Step 5: Turn the inspector into a bottom panel below lg (line 403)**
```tsx
<aside
className={`overflow-auto border-border bg-surface p-4 lg:block lg:border-l ${
selected === null || !selectedRef ? "hidden" : "block border-t max-lg:max-h-[60dvh]"
}`}
>
```
Below `lg` the inspector is hidden until a step is selected — an empty "Select a step to configure it" panel is noise on a phone — and when shown it sits under the canvas with a top border and a capped height. Above `lg` it is the left-bordered right rail it always was, always visible.
- [ ] **Step 6: Verify**
```bash
npx next lint && npx next build
```
Expected: both succeed.
- [ ] **Step 7: Commit**
```bash
git add "web/app/(app)/workflows/[id]/page.tsx"
git commit -m "feat(web): single-column workflow builder below lg"
```
---
### Task 7: Remaining fixed layouts
**Files:**
- Modify: `web/app/(app)/servers/[id]/page.tsx:164,495`
- Modify: `web/app/(app)/secrets/page.tsx:53`
- Modify: `web/app/(app)/workflows/[id]/runs/[runId]/page.tsx:~250`
- Modify: `web/app/(app)/servers/[id]/console/page.tsx:168` and its header rows
**Interfaces:**
- Consumes: nothing. Produces: nothing.
- [ ] **Step 1: `servers/[id]/page.tsx` line 164 — inventory grid**
`className="grid grid-cols-3 gap-2"``className="grid grid-cols-2 gap-2 sm:grid-cols-3"`
- [ ] **Step 2: `servers/[id]/page.tsx` line 495 — install one-liner**
`className="relative flex-1 min-w-64 rounded-lg border border-border bg-well px-4 py-2.5 font-mono text-sm"` → replace `min-w-64` with `min-w-0 overflow-x-auto`.
`min-w-64` is 256px of floor on a flex child; combined with a sibling copy button it pushes the row past a 390px viewport and scrolls the whole page sideways. `min-w-0` lets the box shrink and scroll its own content instead. Also check the parent flex row a few lines above and give it `flex-wrap` if the copy button ends up crushed.
- [ ] **Step 3: `secrets/page.tsx` line 53**
`className="grid grid-cols-2 gap-3"``className="grid grid-cols-1 gap-3 sm:grid-cols-2"`
- [ ] **Step 4: `workflows/[id]/runs/[runId]/page.tsx` — the step matrix**
Read the file around lines 240290. The matrix `<table>` has a `<th className="min-w-[240px] …">`. It is a genuine two-dimensional matrix (steps × servers) and must keep scrolling horizontally rather than stacking — stacking would destroy the information.
Confirm the `<table>` sits inside a wrapper with `overflow-x-auto`. If it does not, wrap it:
```tsx
<div className="overflow-x-auto">
<table >
</table>
</div>
```
If a wrapper already exists, leave it alone and note that in the commit body.
- [ ] **Step 5: `servers/[id]/console/page.tsx`**
Line 168: `className="flex h-full flex-col p-8"``className="flex h-full min-h-0 flex-1 flex-col p-4 sm:p-6 lg:p-8"`.
`flex-1` is added because Task 2 changed the parent `<main>` from `flex-1 overflow-y-auto` to `flex min-w-0 flex-1 flex-col`, so `h-full` alone may no longer resolve to anything. If Task 2 Step 5 recorded a note about this file, resolve it here.
Line 161's error state also has a bare `p-8` — Task 3 should already have handled it. Confirm it reads `p-4 sm:p-6 lg:p-8`.
Then read the connected-state toolbar below line 220 and add `flex-wrap` to any `flex items-center` row that holds three or more controls, so the console's chrome wraps instead of overflowing.
- [ ] **Step 6: Verify**
```bash
npx next lint && npx next build
```
Expected: both succeed.
- [ ] **Step 7: Commit**
```bash
git add web/app
git commit -m "feat(web): collapse remaining fixed layouts on small screens"
```
---
### Task 8: Final audit
**Files:** none modified unless the audit finds a miss.
- [ ] **Step 1: No unprefixed `p-8` remains**
```bash
cd web && grep -rn 'className="[^"]*\bp-8\b' app components | grep -v "sm:p-8\|lg:p-8"
```
Expected: no output.
- [ ] **Step 2: No unprefixed multi-column grid remains**
```bash
cd web && grep -rnoE '(class|className)="[^"]*(^|[" ])grid-cols-[2-9]' app components
```
Every hit must be a `grid-cols-2` that is genuinely fine at 390px (two short items side by side). Check each one and note the justification. Anything holding form controls or long text must gain a `grid-cols-1 sm:` prefix.
- [ ] **Step 3: No fixed pixel width escapes a breakpoint prefix**
```bash
cd web && grep -rnoE '(^|[" ])(w|min-w|max-w)-\[[0-9]{3,}px\]' app components
```
Expected: only `lg:`-prefixed hits, plus `max-w-[340px]` and `max-w-[1180px]` and `max-w-[300px]`, which are all *maximums* and shrink freely. A bare `w-[NNNpx]` or `min-w-[NNNpx]` without a prefix is a defect — except `min-w-[240px]` in the run-detail matrix, which is deliberate (Task 7 Step 4).
- [ ] **Step 4: No hex colours were introduced**
```bash
cd web && git diff main --stat && git diff main -- app components | grep -nE '^\+.*#[0-9a-fA-F]{3,8}\b'
```
Expected: no output from the grep. Tailwind in this app maps `var(--…)` tokens only.
- [ ] **Step 5: Full build and lint**
```bash
npx next lint
npx next build
```
Expected: both succeed with no new warnings.
- [ ] **Step 6: Read the diff end to end**
```bash
git diff main -- web/
```
Check for: an accidentally deleted SVG path, a `Td` whose `label` does not match its `Th`, indentation reformatted in a file that used the other convention.
- [ ] **Step 7: Commit any fixes**
```bash
git add web
git commit -m "fix(web): mobile audit corrections"
```
If the audit found nothing, skip this step — do not create an empty commit.
---
## Self-review notes
**Spec coverage:** shell → Task 2; tables → Tasks 1 and 4; padding and headers → Task 3; modal → Task 5; workflow builder → Task 6; remaining fixed layouts → Task 7; verification → Task 8 plus a verify step in every task.
**Known limitation:** there is no test framework and no running backend in this environment, so no task can prove a page *looks* right — only that it compiles, lints, and contains no pattern known to break at 390px. The first person to open this on a phone should expect to find something. That is a property of the verification approach chosen in the spec, not a gap in the plan.
@@ -0,0 +1,177 @@
# Control plane (`web/`) — mobile responsive design
Date: 2026-07-27
Scope: `web/` only. `site/` and `adminsite/` are untouched.
## Problem
`web/` was built for a desktop console and has no mobile handling at all.
- `Sidebar` is a fixed `w-60 h-screen` aside rendered unconditionally by
`app/(app)/layout.tsx`. On a 390px phone it eats 62% of the width.
- Every page opens with `p-8` — 64px of horizontal padding on a screen that has
390px to give.
- Six list pages render 46 column tables. They scroll horizontally, so nothing
overflows the page, but reading a row means swiping.
- The workflow builder is a hard `grid-cols-[1fr_320px]` with `w-[340px]` nodes.
At 390px the inspector alone exceeds the viewport.
- Several grids are unprefixed (`grid-cols-3`, `grid-cols-2`, `grid-cols-4`) and
never collapse.
Next's App Router injects `width=device-width, initial-scale=1` by default, so
the breakpoints *do* fire. This is a layout problem, not a viewport one.
## Decisions
| Decision | Choice | Why |
| --- | --- | --- |
| Sidebar collapse breakpoint | `lg` (< 1024px) | Content is dense — tables plus `lg:grid-cols-3` side rails. Reclaiming 240px helps tablets as much as phones, and `lg` is already where the app's own two-and-three column layouts switch. |
| Table treatment on phones | Card stack below `sm` | Horizontal swiping to read a hostname's status is the single worst thing about the current app on a phone. |
| Workflow builder / console | Best-effort responsive | Usable, not redesigned. No blocking notice — a cramped console beats no console. |
| Verification | Static audit + `next build` + `next lint` | The app is auth-gated behind Mongo, Redis and the Go server; none run in this environment. |
## Design
### 1. The shell
A new client component `web/components/AppShell.tsx` owns the responsive chrome
so `app/(app)/layout.tsx` stays a server component:
```
AppShell (client, holds `open` state)
├── <aside class="hidden lg:flex"> ← permanent sidebar, unchanged look
├── mobile top bar (lg:hidden, sticky, h-14)
│ hamburger · Logo · "Vantage" · instance name
├── offcanvas (lg:hidden, fixed inset-0 z-50)
│ backdrop (bg-black/60) + w-72 panel, translate-x transition
└── <main class="flex-1 overflow-y-auto"> ← LicenseBanner + children
```
`Sidebar.tsx` splits into:
- `SidebarContent` — the nav list, user block and logout. **One copy**, rendered
by both the permanent aside and the offcanvas panel. It takes an optional
`onNavigate` callback so the offcanvas can close on link click.
- `Sidebar` — the permanent `hidden lg:flex` aside.
- `SidebarDrawer` — the offcanvas.
`navItems` and the `activeHref` reduction move to module scope so both
containers share them. The active-item accent bar, the instance name in the
header and the user/logout footer all appear in both, unchanged.
Offcanvas behaviour:
- Closes on route change (`usePathname` effect), on Escape, on backdrop click
and on any nav link click.
- Locks `document.body.style.overflow` while open, restores on close.
- `aria-expanded` / `aria-controls` on the hamburger; `role="dialog"` and
`aria-modal="true"` on the panel; `aria-label` on the button.
- Focus moves into the panel on open and returns to the hamburger on close.
- The panel is always mounted so the slide transition runs in both directions;
it carries `pointer-events-none invisible` when closed rather than being
unmounted.
The top bar is `sticky top-0 z-40` inside the scroll container so it stays
reachable on long pages.
### 2. Tables become card stacks without duplicating markup
The responsive mode lives in the primitives (`web/components/ui/Table.tsx`),
not in each page. Writing two parallel trees per page — a `<table>` for desktop
and a `<div>` stack for mobile — would double six pages of markup and drift
apart on the first edit.
`Td` gains an optional `label`. Below `sm` the table flips to block layout:
| Element | Added classes (below `sm`) |
| --- | --- |
| `Table` | `max-sm:block` |
| `Thead` | `max-sm:hidden` |
| `Tbody` | `max-sm:block max-sm:divide-y-0 max-sm:space-y-3 max-sm:p-3` |
| `Tr` | `max-sm:block max-sm:rounded max-sm:border max-sm:border-border max-sm:bg-surface-2/40 max-sm:p-3` |
| `Td` | `max-sm:flex max-sm:items-start max-sm:justify-between max-sm:gap-4 max-sm:px-0 max-sm:py-1.5` |
When `label` is present, `Td` renders it in a `sm:hidden` span using the exact
mono keyed-label idiom `Th` already uses — `font-mono text-[0.68rem] uppercase
tracking-[0.13em] text-text-secondary`. The key/value pairing on a phone is the
same visual device as the column head on a desktop, because it means the same
thing.
A `Td` with no `label` (the trailing action cell) renders its child alone,
right-aligned in the card.
Pages change only by adding `label="Hostname"` to their cells. Affected:
`servers`, `keys`, `monitors`, `secrets`, `secrets/[group]`, `workflows`,
`workflows/[id]/runs`, `audit`, `keys/[id]`, `servers/[id]` (two tables),
`monitors/[id]`, and `components/settings/MembersCard.tsx`.
### 3. Page padding and headers
- `p-8``p-4 sm:p-6 lg:p-8`, everywhere it opens a page or a page-level
error/loading state — 30 occurrences across 21 files.
- Title-plus-action header rows: `flex items-center justify-between`
`flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between`. The
action button then sits under the title on a phone rather than squeezing it.
### 4. Modal becomes a bottom sheet under `sm`
`Modal.tsx`: `items-center``items-end sm:items-center`, wrapper `p-4`
`p-0 sm:p-4`, panel gets `rounded-b-none sm:rounded` and `max-h-[85dvh]`
(`dvh`, not `vh` — mobile browser chrome makes `vh` overshoot). Sheets are what
phones expect for a modal, and it costs four classes.
### 5. Workflow builder
Below `lg` the fixed-height two-column grid is dropped entirely: single column,
natural page flow, canvas scrolls with the page.
- `grid h-[calc(100vh-53px)] grid-cols-[1fr_320px]`
`flex flex-col lg:grid lg:h-[calc(100dvh-53px)] lg:grid-cols-[1fr_320px]`.
The viewport-height calculation is `lg:`-only, which matters because the
mobile top bar changes the arithmetic and `100vh` is wrong on mobile anyway.
- Node width `w-[340px]``w-full lg:w-[340px]`; the column wrapper
`w-[340px]``w-full max-w-[340px]`.
- Canvas padding `p-8``p-4 sm:p-6 lg:p-8`.
- The inspector `<aside>` becomes a collapsible bottom panel below `lg`: it
keeps its place in the flex column, gains a top border instead of a left one,
and is hidden until a step is selected (on a phone an empty "Select a step to
configure it" panel is noise).
- The builder's own header row wraps: the action cluster moves to a second line
under `sm`.
### 6. Remaining fixed layouts
| File | Change |
| --- | --- |
| `servers/[id]/page.tsx:164` | `grid-cols-3``grid-cols-2 sm:grid-cols-3` |
| `servers/[id]/page.tsx:495` | install one-liner `min-w-64``min-w-0` so it scrolls internally instead of widening the page |
| `secrets/page.tsx:53` | `grid-cols-2``grid-cols-1 sm:grid-cols-2` |
| `monitors/MonitorForm.tsx:76` | `grid-cols-4``grid-cols-2 sm:grid-cols-4` |
| `monitors/MonitorForm.tsx:98,123,144` | `grid-cols-2``grid-cols-1 sm:grid-cols-2` |
| `workflows/StepPickerModal.tsx:132,168` | `grid-cols-2``grid-cols-1 sm:grid-cols-2` |
| `workflows/[id]/runs/[runId]/page.tsx:254` | matrix table wrapped in `overflow-x-auto`; it is a genuine matrix and stays scrollable |
| `workflows/[id]/runs/[runId]/page.tsx:343` | `p-8``p-4 sm:p-6 lg:p-8` |
| `servers/[id]/console/page.tsx:168` | `p-8``p-4 sm:p-6 lg:p-8`; header/toolbar rows wrap |
The run-detail matrix and the console canvas are the two places that keep
horizontal scrolling. Both are genuinely two-dimensional; stacking them would
destroy the information.
## Non-goals
- No changes to `site/` or `adminsite/`.
- No redesign of the console for touch input (no on-screen keyboard work).
- No new dependencies. Tailwind's `max-sm:` variant and `translate-x` are
enough; no headless-UI or animation library.
- No changes to any API, route or data shape. This is presentation only.
## Verification
1. **Audit** — after the edits, `grep` must return no unprefixed `p-8`,
no unprefixed `grid-cols-[2-9]`, and no `w-[3` fixed node widths outside a
`lg:` prefix in `web/app` and `web/components`.
2. `npx next lint` passes with no new warnings.
3. `npx next build` succeeds.
Screenshot verification is out of scope: the app is auth-gated behind Mongo,
Redis and the Go server, none of which run in this environment.
+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)} />
))}