Compare commits
10
Commits
20e57d19c7
...
14a11886ae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14a11886ae | ||
|
|
4be7d24aec | ||
|
|
dd4ce5bb3e | ||
|
|
5ee3f14eed | ||
|
|
5dcc1bf1be | ||
|
|
facef270b7 | ||
|
|
e8c75e974d | ||
|
|
60af88525c | ||
|
|
74f6dca2f5 | ||
|
|
effd991c31 |
@@ -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 4–6px 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 116–145 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 142–143, 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 28–34:
|
||||
|
||||
```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 240–290. 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 4–6 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.
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)} />
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user