mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 14:14:47 +00:00
Merge pull request #1088 from kaitranntt/kai/feat/design-system-foundations
feat(ui): add design system foundations (PageShell + ConfigLayout + MonitorLayout)
This commit is contained in:
25 files changed
+1712
No files matched your search
@@ -0,0 +1,18 @@
|
||||
# Design System — Decisions Log
|
||||
|
||||
The 6 open questions from the brainstorm phase, resolved before implementation.
|
||||
|
||||
| # | Question | Decision | Rationale |
|
||||
|---|----------|----------|-----------|
|
||||
| 1 | Storybook vs in-app `/_styleguide` | **In-app `/_styleguide`** | Zero-config, lives in the repo, gated by `import.meta.env.DEV`. Storybook is a heavy second build pipeline for marginal benefit at this scale. |
|
||||
| 2 | Archetype B name (Monitor vs Dashboard) | **Monitor** | "Dashboard" is the whole product. "Monitor" matches existing health/analytics framing and avoids overloading the term. |
|
||||
| 3 | JsonPane editability scope | **Read-only by default**, opt-in `editable` prop | Cliproxy is the only page that genuinely needs in-pane editing today. Opt-in keeps the API safe for codex/copilot/cursor (read-only). |
|
||||
| 4 | Health terminal aesthetic | **Keep** as `MonitorCard variant="terminal"` | Preserves the `ccs health --watch` feel. Implemented as opt-in variant, not a separate primitive. |
|
||||
| 5 | i18n key namespace convention | **Per-page namespaces** (`pages.<name>.*`) + shared (`common.*`) | Current i18n already uses ad-hoc per-page keys; this just formalizes it. Primitives use `common.*` so they're translation-stable across pages. |
|
||||
| 6 | SectionRail activation | **Scroll-spy** via `IntersectionObserver` | Preserves long-form config feel + shows all validation errors at once. Click-to-switch hides errors in inactive sections, which is worse UX for forms. |
|
||||
|
||||
---
|
||||
|
||||
## How to revisit
|
||||
|
||||
If a decision turns out wrong in practice, update this doc and bump the affected primitive — don't silently drift. Each row above should be appended with a "Revised: <date> · <reason>" line if changed.
|
||||
@@ -0,0 +1,122 @@
|
||||
<!doctype html><html lang="en"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>CCS Dashboard Design System — Phase 1 Preview</title>
|
||||
<style>
|
||||
:root{--bg:#0b0d12;--panel:#11151c;--line:#222a37;--ink:#e6edf6;--muted:#8a95a8;--dim:#5a6478;--accent:#7cc3ff;--good:#5ee2a0;--warn:#f5c46b;--bad:#ff7676;--chip:#1c2230}
|
||||
*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.55 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
||||
code,pre,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
||||
.wrap{max-width:1240px;margin:0 auto;padding:32px 24px 80px}
|
||||
h1{font-size:28px;margin:0 0 6px}
|
||||
h2{font-size:18px;margin:36px 0 12px;border-bottom:1px solid var(--line);padding-bottom:6px}
|
||||
h3{font-size:13px;margin:18px 0 8px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em}
|
||||
p{color:#c8d2e0}
|
||||
.lede{color:var(--muted);max-width:80ch}
|
||||
.pill{display:inline-block;padding:2px 8px;border:1px solid var(--line);border-radius:999px;background:var(--chip);color:var(--muted);font-size:11px;margin-right:4px}
|
||||
.pill.good{color:var(--good);border-color:#274d3a}
|
||||
.pill.warn{color:var(--warn);border-color:#4d3f22}
|
||||
.pill.accent{color:var(--accent);border-color:#264866}
|
||||
.row{display:flex;gap:16px;flex-wrap:wrap}
|
||||
.shot{display:block;width:100%;border:1px solid var(--line);border-radius:10px;background:#000;margin:8px 0}
|
||||
.callout{border-left:3px solid var(--accent);background:#0e1620;padding:10px 14px;border-radius:0 6px 6px 0;margin:10px 0}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
th{color:var(--muted);font-weight:600;font-size:12px;text-transform:uppercase;letter-spacing:.06em}
|
||||
.tree{font-family:ui-monospace,Menlo,monospace;font-size:12.5px;color:#cdd6e3;background:#0e1219;border:1px solid var(--line);border-radius:8px;padding:14px;white-space:pre}
|
||||
.tree b{color:var(--accent);font-weight:600}
|
||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||
@media (max-width:980px){.grid2{grid-template-columns:1fr}}
|
||||
.head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px;margin-bottom:8px}
|
||||
.kbd{font-family:ui-monospace,Menlo,monospace;font-size:12px;background:#10151d;border:1px solid var(--line);padding:1px 6px;border-radius:4px;color:#cdd6e3}
|
||||
</style></head><body>
|
||||
<div class="wrap">
|
||||
<header class="head">
|
||||
<div>
|
||||
<h1>CCS Dashboard Design System — Phase 1</h1>
|
||||
<p class="lede">Force-UI visual review of the foundations PR. Two locked archetypes (Config 3-pane and Monitor 12-col grid) wrapped by a shared <code>PageShell</code>. Every page going forward picks one.</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="pill accent">PR #1088</span>
|
||||
<span class="pill good">Privacy mode ON</span>
|
||||
<span class="pill">/_styleguide · DEV ONLY</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<h2>1 · Shared shell</h2>
|
||||
<div class="tree"><b>PageShell</b>
|
||||
├─ <b>PageHeader</b> (title · description · status · actions)
|
||||
└─ <b>PageBody</b>
|
||||
├─ <b>ConfigLayout</b> ListPane │ FormPane │ JsonPane ← config archetype
|
||||
└─ <b>MonitorLayout</b> KpiRow + MonitorGrid(MonitorCard…) ← monitor archetype</div>
|
||||
|
||||
<h2>2 · /_styleguide overview</h2>
|
||||
<p class="lede">Dev-only route gated by <code>import.meta.env.DEV</code>. Renders every primitive plus composed Config + Monitor demos. Privacy toggle on; all data anonymized as Provider A/B/C.</p>
|
||||
<img class="shot" src="data:image/png;base64,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 truncated
|
||||
|
||||
<h2>3 · Config archetype — multi-entity (ListPane)</h2>
|
||||
<p class="lede">Used by cliproxy, accounts, providers. Left rail = list of entities. Save lives only in FormPane footer.</p>
|
||||
<img class="shot" src="data:image/png;base64,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 truncated
|
||||
|
||||
<h2>4 · Config archetype — single-entity (SectionRail)</h2>
|
||||
<p class="lede">Used by codex, copilot, cursor, droid, claude-extension. Same 3-pane shell — left rail swaps to anchor nav with <code>IntersectionObserver</code> scroll-spy. Solves the long-scroll problem on big single-entity pages.</p>
|
||||
<img class="shot" src="data:image/png;base64,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 truncated
|
||||
|
||||
<h2>5 · Monitor archetype</h2>
|
||||
<p class="lede">Used by home, analytics, health, logs. KPI row + 12-col responsive grid. <code>MonitorCard variant="terminal"</code> preserves the <code>ccs health --watch</code> aesthetic as opt-in.</p>
|
||||
<img class="shot" src="data:image/png;base64,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 truncated
|
||||
|
||||
<h2>6 · Primitive map</h2>
|
||||
<div class="grid2">
|
||||
<div><h3>Page shell</h3><div class="tree"><b>components/page-shell/</b>
|
||||
├─ page-shell.tsx
|
||||
├─ page-header.tsx
|
||||
├─ empty-state.tsx
|
||||
└─ error-state.tsx</div></div>
|
||||
<div><h3>Config (3-pane)</h3><div class="tree"><b>components/config-layout/</b>
|
||||
├─ config-layout.tsx
|
||||
├─ list-pane.tsx
|
||||
├─ section-rail.tsx
|
||||
├─ form-pane.tsx
|
||||
├─ form-section.tsx
|
||||
└─ json-pane.tsx</div></div>
|
||||
<div><h3>Monitor (grid)</h3><div class="tree"><b>components/monitor-layout/</b>
|
||||
├─ monitor-layout.tsx
|
||||
├─ kpi.tsx (KpiRow + KpiCard)
|
||||
└─ monitor-grid.tsx (MonitorGrid + MonitorCard)</div></div>
|
||||
<div><h3>Out of scope</h3><div class="tree">login · setup wizard · dialogs
|
||||
|
||||
These keep their own minimal
|
||||
shells. Don't force them into
|
||||
an archetype.</div></div>
|
||||
</div>
|
||||
|
||||
<h2>7 · Locked decisions</h2>
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>Question</th><th>Decision</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>1</td><td>Storybook vs in-app</td><td><b>In-app /_styleguide</b> — zero-config, lives in repo, dev-only</td></tr>
|
||||
<tr><td>2</td><td>Archetype B name</td><td><b>Monitor</b> (Dashboard is the whole product)</td></tr>
|
||||
<tr><td>3</td><td>JsonPane editability</td><td><b>Read-only by default</b>, opt-in <code>editable</code> prop</td></tr>
|
||||
<tr><td>4</td><td>Health terminal aesthetic</td><td><b>Keep</b> as <code>variant="terminal"</code></td></tr>
|
||||
<tr><td>5</td><td>i18n namespace</td><td><b>Per-page</b> (<code>pages.<name>.*</code>) + shared (<code>common.*</code>)</td></tr>
|
||||
<tr><td>6</td><td>SectionRail activation</td><td><b>Scroll-spy</b> via <code>IntersectionObserver</code></td></tr>
|
||||
</tbody></table>
|
||||
|
||||
<h2>8 · How to verify locally</h2>
|
||||
<div class="callout">
|
||||
<code class="kbd">cd ui && bun run dev</code> then visit <code class="kbd">http://localhost:5173/_styleguide</code><br>
|
||||
The route only exists when <code>import.meta.env.DEV === true</code>; production builds exclude it entirely.
|
||||
</div>
|
||||
|
||||
<h2>9 · What lands next</h2>
|
||||
<table>
|
||||
<thead><tr><th>Phase</th><th>Scope</th><th>PRs</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>2</td><td>Reference migrations: cliproxy, home, health</td><td>1 PR</td></tr>
|
||||
<tr><td>3</td><td>Opportunistic conversion: codex, copilot, accounts, api, shared, analytics, logs, updates</td><td>~8 PRs (smallest first)</td></tr>
|
||||
<tr><td>4</td><td>Outlier rewrites: droid (749) → claude-extension (1118) → cursor (1413) → cliproxy-ai-providers (~1700)</td><td>4 PRs</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
<p style="margin-top:32px;color:var(--dim);font-size:12px">
|
||||
Source: <code>plans/260425-1132-ccs-dashboard-design-system/</code> · Brainstorm: <code>brainstorm-260425-1115-ccs-dashboard-design-system.md</code>
|
||||
</p>
|
||||
</div></body></html>
|
||||
@@ -0,0 +1,179 @@
|
||||
# CCS Dashboard Design System
|
||||
|
||||
The dashboard ships **two page archetypes**. Every page picks exactly one. All pages share the same outer chrome via `PageShell` + `PageHeader`.
|
||||
|
||||
> Live preview in dev: `bun run dev` then visit `/_styleguide`.
|
||||
|
||||
---
|
||||
|
||||
## 1. The shell — every page
|
||||
|
||||
```
|
||||
PageShell
|
||||
├─ PageHeader (title · description · status · actions)
|
||||
└─ <archetype body>
|
||||
```
|
||||
|
||||
```tsx
|
||||
<PageShell>
|
||||
<PageHeader title="Cliproxy" status={…} actions={…} />
|
||||
<ConfigLayout … /> {/* OR <MonitorLayout> */}
|
||||
</PageShell>
|
||||
```
|
||||
|
||||
Header rules:
|
||||
- `title` is mandatory; `description` optional 1-line subtitle.
|
||||
- `status` slot: small badges/chips (e.g. "Running · :8317").
|
||||
- `actions` slot: button group; **primary action belongs in FormPane footer**, not here, for Config pages.
|
||||
|
||||
---
|
||||
|
||||
## 2. Config archetype — 3-pane
|
||||
|
||||
Used by every page that configures one or more entities (cliproxy, accounts, codex, copilot, cursor, droid, claude-extension, api, shared, profiles).
|
||||
|
||||
```
|
||||
┌──────────┬──────────────────┬──────────┐
|
||||
│ left │ form (FormPane) │ json │
|
||||
│ rail │ │ (right) │
|
||||
└──────────┴──────────────────┴──────────┘
|
||||
```
|
||||
|
||||
One component, prop-controlled left rail:
|
||||
|
||||
```tsx
|
||||
// Multi-entity (cliproxy, accounts, providers)
|
||||
<ConfigLayout
|
||||
left={<ListPane items={…} selectedId={id} onSelect={…} />}
|
||||
form={<FormPane>…</FormPane>}
|
||||
json={<JsonPane data={…} />}
|
||||
/>
|
||||
|
||||
// Single-entity (codex, copilot, cursor, droid, claude-extension)
|
||||
<ConfigLayout
|
||||
left={<SectionRail sections={…} />}
|
||||
form={<FormPane>…</FormPane>}
|
||||
json={<JsonPane data={…} />}
|
||||
/>
|
||||
|
||||
// No rail (rare — only if page has neither entities to pick nor sections to navigate)
|
||||
<ConfigLayout form={<FormPane>…</FormPane>} json={<JsonPane data={…} />} />
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
- **Layout**: left 260px / form flex / json 360px on `>=1024px`.
|
||||
- **<1024px**: collapses to tabs (`Browse | Configure | JSON`).
|
||||
- **Save action**: lives **only** in `FormPane footer`. Never duplicate in PageHeader.
|
||||
- **Per-entity actions** (delete, duplicate, sync): ListPane row trailing actions or FormPane header secondary actions — never both.
|
||||
- **JsonPane is read-only by default**. Pass `editable` prop only on pages that need inline editing (cliproxy is the canonical example).
|
||||
|
||||
### SectionRail
|
||||
|
||||
For single-entity pages. Provides anchor nav with `IntersectionObserver` scroll-spy. Each item must match the `id` of a `<FormSection id="…">` in the FormPane.
|
||||
|
||||
```tsx
|
||||
<SectionRail
|
||||
sections={[
|
||||
{ id: 'general', label: 'General' },
|
||||
{ id: 'auth', label: 'Authentication' },
|
||||
{ id: 'routing', label: 'Routing' },
|
||||
]}
|
||||
/>
|
||||
|
||||
// FormPane:
|
||||
<FormSection id="general" title="General">…</FormSection>
|
||||
<FormSection id="auth" title="Authentication">…</FormSection>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Monitor archetype — KPI row + 12-col grid
|
||||
|
||||
Used by every page that shows live state (home, analytics, health, logs, future status pages).
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────┐
|
||||
│ KpiRow: [KPI] [KPI] [KPI] [KPI] │ (optional, ≤4 hero numbers)
|
||||
├────────────────────────────────────────────────┤
|
||||
│ MonitorGrid (12-col): │
|
||||
│ <MonitorCard span={8}> primary viz │
|
||||
│ <MonitorCard span={4}> side widget │
|
||||
│ <MonitorCard span={6}> ... │
|
||||
│ <MonitorCard span={6}> ... │
|
||||
└────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
```tsx
|
||||
<MonitorLayout
|
||||
kpis={
|
||||
<KpiRow>
|
||||
<KpiCard label="Active accounts" value="87" hint="▲ 3" tone="positive" />
|
||||
…
|
||||
</KpiRow>
|
||||
}
|
||||
>
|
||||
<MonitorGrid>
|
||||
<MonitorCard span={8} title="Live monitor">…</MonitorCard>
|
||||
<MonitorCard span={4} title="Top providers">…</MonitorCard>
|
||||
<MonitorCard span={6} title="Requests" meta="last 24h">…</MonitorCard>
|
||||
<MonitorCard span={6} variant="terminal" title="$ ccs health --watch">…</MonitorCard>
|
||||
</MonitorGrid>
|
||||
</MonitorLayout>
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
- **KpiRow** only when there are ≤4 hero numbers. More than 4 → use `MonitorCard`s inside the grid instead.
|
||||
- **One primary viz per page** — span ≥8 cols. Preserves the "punch" of home and analytics.
|
||||
- **`variant="terminal"`** = dark monospace card, for live-log / `health --watch` aesthetics. Opt-in only.
|
||||
- **Spans clamp at 6 cols on tablet**, single column on mobile.
|
||||
|
||||
---
|
||||
|
||||
## 4. When NOT to use either archetype
|
||||
|
||||
These remain bespoke:
|
||||
- `/login` — minimal centered shell, no header/grid.
|
||||
- Setup wizard — modal overlay, not a page.
|
||||
- Dialogs — radix `Dialog`, not a layout.
|
||||
|
||||
If you find yourself fighting the shell, the answer is usually "this isn't a Config or Monitor page" — talk to the maintainers before inventing a third archetype.
|
||||
|
||||
---
|
||||
|
||||
## 5. Composing a new page
|
||||
|
||||
The recipe for any new page is:
|
||||
|
||||
```tsx
|
||||
import { PageShell, PageHeader } from '@/components/page-shell';
|
||||
import { ConfigLayout, /*…*/ } from '@/components/config-layout';
|
||||
|
||||
export function MyPage() {
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader title="My Page" actions={<Actions/>} />
|
||||
<ConfigLayout
|
||||
left={…}
|
||||
form={<FormPane>…</FormPane>}
|
||||
json={<JsonPane data={…}/>}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Target LOC for a new page: **~80** for typical config, **~120** for monitor. If your page exceeds 400 LOC it should be split into `pages/<name>/` with section files.
|
||||
|
||||
---
|
||||
|
||||
## 6. Lint / enforcement
|
||||
|
||||
Phase 4 adds a lint rule: every file under `src/pages/*.tsx` (or `src/pages/*/index.tsx`) must import `PageShell`. Until then, code review enforces the contract.
|
||||
|
||||
---
|
||||
|
||||
## 7. Decisions
|
||||
|
||||
See [`design-decisions.md`](./design-decisions.md) for the resolutions of the 6 open questions from the brainstorm phase.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 149 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 131 KiB |
@@ -47,6 +47,11 @@ const HealthPage = lazy(() => import('@/pages/health').then((m) => ({ default: m
|
||||
const SharedPage = lazy(() => import('@/pages/shared').then((m) => ({ default: m.SharedPage })));
|
||||
const UpdatesPage = lazy(() => import('@/pages/updates').then((m) => ({ default: m.UpdatesPage })));
|
||||
|
||||
// Dev-only: design system styleguide
|
||||
const StyleguidePage = import.meta.env.DEV
|
||||
? lazy(() => import('@/pages/_styleguide').then((m) => ({ default: m.StyleguidePage })))
|
||||
: null;
|
||||
|
||||
// Loading fallback for lazy components
|
||||
function PageLoader() {
|
||||
return (
|
||||
@@ -203,6 +208,16 @@ export default function App() {
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
{StyleguidePage && (
|
||||
<Route
|
||||
path="/_styleguide"
|
||||
element={
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<StyleguidePage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const DESKTOP_BREAKPOINT_PX = 1024;
|
||||
|
||||
/**
|
||||
* Track whether the viewport meets the desktop breakpoint.
|
||||
* Used to render EITHER the 3-pane grid OR the mobile tabs — never both.
|
||||
* Rendering both at once duplicates FormSection ids in the DOM, which breaks
|
||||
* SectionRail scroll-spy (getElementById returns the hidden desktop copy first).
|
||||
*/
|
||||
function useIsDesktop(): boolean {
|
||||
const [isDesktop, setIsDesktop] = useState(() =>
|
||||
typeof window === 'undefined'
|
||||
? true // SSR-safe default; harmless on first paint
|
||||
: window.matchMedia(`(min-width: ${DESKTOP_BREAKPOINT_PX}px)`).matches
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(`(min-width: ${DESKTOP_BREAKPOINT_PX}px)`);
|
||||
const onChange = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
||||
mql.addEventListener('change', onChange);
|
||||
return () => mql.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
return isDesktop;
|
||||
}
|
||||
|
||||
interface ConfigLayoutProps {
|
||||
/** Left rail: <ListPane> for multi-entity, <SectionRail> for single-entity, omit for none. */
|
||||
left?: ReactNode;
|
||||
/** Middle pane: form. */
|
||||
form: ReactNode;
|
||||
/** Right pane: raw JSON / effective config. Omit to hide. */
|
||||
json?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ConfigLayout - Strict 3-pane shell for every Config archetype page.
|
||||
*
|
||||
* - >=1024px: 3-column grid (left 260px / form flex / json 360px)
|
||||
* - <1024px: tabs (left | form | json)
|
||||
*
|
||||
* Single component, prop-controlled left rail. The contract:
|
||||
* - <ConfigLayout left={<ListPane …/>} …/> // multi-entity
|
||||
* - <ConfigLayout left={<SectionRail …/>} …/> // single-entity
|
||||
* - <ConfigLayout …/> // no rail
|
||||
*/
|
||||
export function ConfigLayout({ left, form, json, className }: ConfigLayoutProps) {
|
||||
const isDesktop = useIsDesktop();
|
||||
|
||||
// CRITICAL: render exactly one layout at a time. Rendering both and
|
||||
// toggling visibility via Tailwind `hidden lg:grid` would mount two copies
|
||||
// of every FormSection — SectionRail's scroll-spy and click-to-jump use
|
||||
// document.getElementById() which would resolve to the (hidden) desktop
|
||||
// copy first on mobile, breaking the rail entirely.
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid min-h-0 flex-1 gap-4 p-4',
|
||||
left && json && 'grid-cols-[260px_minmax(0,1fr)_360px]',
|
||||
left && !json && 'grid-cols-[260px_minmax(0,1fr)]',
|
||||
!left && json && 'grid-cols-[minmax(0,1fr)_360px]',
|
||||
!left && !json && 'grid-cols-1',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{left && (
|
||||
<aside className="min-w-0 overflow-hidden rounded-xl border bg-card">{left}</aside>
|
||||
)}
|
||||
<main className="min-w-0 overflow-hidden rounded-xl border bg-card">{form}</main>
|
||||
{json && (
|
||||
<aside className="min-w-0 overflow-hidden rounded-xl border bg-card">{json}</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <MobileTabs left={left} form={form} json={json} className={className} />;
|
||||
}
|
||||
|
||||
function MobileTabs({
|
||||
left,
|
||||
form,
|
||||
json,
|
||||
className,
|
||||
}: Pick<ConfigLayoutProps, 'left' | 'form' | 'json' | 'className'>) {
|
||||
const tabs = [
|
||||
left && { id: 'list', label: 'Browse', node: left },
|
||||
{ id: 'form', label: 'Configure', node: form },
|
||||
json && { id: 'json', label: 'JSON', node: json },
|
||||
].filter(Boolean) as { id: string; label: string; node: ReactNode }[];
|
||||
const [selected, setSelected] = useState(tabs[0]?.id ?? 'form');
|
||||
|
||||
// Derive the effective active tab during render so a parent toggling `left`
|
||||
// or `json` (which changes the available tabs) cannot leave us pointing at
|
||||
// an id that no longer exists. Falls back to the first available tab.
|
||||
const active = tabs.some((t) => t.id === selected) ? selected : (tabs[0]?.id ?? 'form');
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-h-0 flex-1 flex-col gap-3 p-3', className)}>
|
||||
<Tabs value={active} onValueChange={setSelected}>
|
||||
<TabsList className="w-full">
|
||||
{tabs.map((t) => (
|
||||
<TabsTrigger key={t.id} value={t.id} className="flex-1">
|
||||
{t.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{tabs.map((t) => (
|
||||
<TabsContent key={t.id} value={t.id} className="rounded-xl border bg-card">
|
||||
{t.node}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FormPaneProps {
|
||||
/** Sticky header above the scrolling form body. Typically entity name + secondary actions. */
|
||||
header?: ReactNode;
|
||||
/** Form body — typically a stack of <FormSection>s. */
|
||||
children: ReactNode;
|
||||
/** Footer pinned at bottom. Place primary save action here. */
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FormPane - Middle pane of ConfigLayout. Holds the form.
|
||||
*
|
||||
* Layout: optional sticky header, scrolling body, optional sticky footer (for primary actions).
|
||||
*/
|
||||
export function FormPane({ header, children, footer, className }: FormPaneProps) {
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', className)}>
|
||||
{header && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b bg-card px-5 py-3">{header}</div>
|
||||
)}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-4 p-5">{children}</div>
|
||||
</ScrollArea>
|
||||
{footer && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-t bg-card/80 px-5 py-3 backdrop-blur">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FormSectionProps {
|
||||
/** Anchor id — must match SectionRail item id for scroll-spy. */
|
||||
id: string;
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
/** Optional trailing actions in section header. */
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FormSection - Titled card group inside FormPane.
|
||||
*
|
||||
* The `id` prop is REQUIRED and drives SectionRail scroll-spy. Each section in a
|
||||
* single-entity Config page should have a stable, kebab-case id like "general", "auth", "routing".
|
||||
*/
|
||||
export function FormSection({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
}: FormSectionProps) {
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className={cn('scroll-mt-4 rounded-lg border bg-background/40 p-4', className)}
|
||||
>
|
||||
<header className="mb-3 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold tracking-tight">{title}</h3>
|
||||
{description && <p className="mt-0.5 text-xs text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="shrink-0">{actions}</div>}
|
||||
</header>
|
||||
<div className="space-y-3">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { ConfigLayout } from './config-layout';
|
||||
export { ListPane, type ListPaneItem } from './list-pane';
|
||||
export { SectionRail, type SectionRailItem } from './section-rail';
|
||||
export { FormPane } from './form-pane';
|
||||
export { FormSection } from './form-section';
|
||||
export { JsonPane, type JsonTab } from './json-pane';
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { CopyButton } from '@/components/ui/copy-button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface JsonTab {
|
||||
id: string;
|
||||
label: ReactNode;
|
||||
/** Object/array to render. Stringified internally. */
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
interface JsonPaneProps {
|
||||
/** Single source — used when there's only one view. */
|
||||
data?: unknown;
|
||||
/** Multi-tab mode (e.g. Effective | Override | Diff). */
|
||||
tabs?: JsonTab[];
|
||||
title?: ReactNode;
|
||||
/** Accept structural edits. Off by default — Phase 1 ships read-only baseline. */
|
||||
editable?: boolean;
|
||||
/** Called when editable=true and user commits a change. */
|
||||
onChange?: (next: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* JsonPane - Right pane of ConfigLayout. Displays raw / effective configuration.
|
||||
*
|
||||
* Read-only by default. Pass `editable` to opt-in to inline editing (cliproxy uses this).
|
||||
* Single-source via `data` OR multi-tab via `tabs` (e.g. effective / override / diff).
|
||||
*/
|
||||
export function JsonPane({
|
||||
data,
|
||||
tabs,
|
||||
title = 'Configuration',
|
||||
editable = false,
|
||||
onChange,
|
||||
className,
|
||||
}: JsonPaneProps) {
|
||||
const hasTabs = tabs && tabs.length > 0;
|
||||
const [selectedTabId, setSelectedTabId] = useState<string>(tabs?.[0]?.id ?? 'data');
|
||||
|
||||
// Derive the *effective* active tab during render rather than mutating state
|
||||
// in an effect. If the parent swaps the `tabs` array (e.g. selects a different
|
||||
// entity), the previous selectedTabId may no longer exist — fall back to the
|
||||
// first available tab so the pane never shows empty content.
|
||||
const activeTab =
|
||||
hasTabs && tabs.some((t) => t.id === selectedTabId) ? selectedTabId : (tabs?.[0]?.id ?? 'data');
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', className)}>
|
||||
<header className="flex shrink-0 items-center justify-between gap-2 border-b px-4 py-2.5">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
<CopyButton
|
||||
value={
|
||||
hasTabs
|
||||
? JSON.stringify(tabs.find((t) => t.id === activeTab)?.data ?? {}, null, 2)
|
||||
: JSON.stringify(data ?? {}, null, 2)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{hasTabs ? (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setSelectedTabId}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<TabsList className="mx-3 mt-2 w-fit">
|
||||
{tabs.map((t) => (
|
||||
<TabsTrigger key={t.id} value={t.id} className="text-xs">
|
||||
{t.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{tabs.map((t) => (
|
||||
<TabsContent key={t.id} value={t.id} className="min-h-0 flex-1 overflow-hidden">
|
||||
<JsonView data={t.data} editable={editable} onChange={onChange} />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
) : (
|
||||
<JsonView data={data} editable={editable} onChange={onChange} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface JsonViewProps {
|
||||
data: unknown;
|
||||
editable: boolean;
|
||||
onChange?: (next: string) => void;
|
||||
}
|
||||
|
||||
function JsonView({ data, editable, onChange }: JsonViewProps) {
|
||||
const text = useMemo(() => JSON.stringify(data ?? {}, null, 2), [data]);
|
||||
|
||||
if (editable) {
|
||||
// `key={text}` forces React to remount the textarea when the underlying data
|
||||
// changes (e.g. parent swaps the selected entity). Without this, an
|
||||
// uncontrolled textarea retains the prior value and onBlur saves stale text.
|
||||
return (
|
||||
<textarea
|
||||
key={text}
|
||||
defaultValue={text}
|
||||
onBlur={(e) => onChange?.(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="h-full w-full resize-none border-0 bg-muted/40 p-3 font-mono text-xs leading-relaxed focus:outline-none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<pre className="whitespace-pre p-3 font-mono text-xs leading-relaxed text-foreground">
|
||||
{text}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface ListPaneItem {
|
||||
id: string;
|
||||
label: ReactNode;
|
||||
badge?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
interface ListPaneProps {
|
||||
items: ListPaneItem[];
|
||||
selectedId?: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
searchValue?: string;
|
||||
onSearchChange?: (v: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
header?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ListPane - Sidebar list of selectable entities (multi-entity Config pages).
|
||||
*
|
||||
* Provides search input + scrollable list + footer slot for "+ Add" actions.
|
||||
*/
|
||||
export function ListPane({
|
||||
items,
|
||||
selectedId,
|
||||
onSelect,
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
searchPlaceholder = 'Search…',
|
||||
header,
|
||||
footer,
|
||||
className,
|
||||
}: ListPaneProps) {
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', className)}>
|
||||
{header && <div className="border-b p-3">{header}</div>}
|
||||
{onSearchChange && (
|
||||
<div className="relative border-b p-2">
|
||||
<Search className="absolute left-4 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchValue ?? ''}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="h-8 pl-7 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className="flex-1">
|
||||
<ul className="p-2">
|
||||
{items.map((item) => {
|
||||
const selected = item.id === selectedId;
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(item.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors',
|
||||
selected
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-accent/50 hover:text-accent-foreground'
|
||||
)}
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
>
|
||||
{item.icon && <span className="shrink-0">{item.icon}</span>}
|
||||
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||
{item.badge && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{item.badge}</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
{footer && <div className="border-t p-2">{footer}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SectionRailItem {
|
||||
id: string;
|
||||
label: ReactNode;
|
||||
badge?: ReactNode;
|
||||
}
|
||||
|
||||
interface SectionRailProps {
|
||||
sections: SectionRailItem[];
|
||||
/** ID of the FormSection element to scroll into view on click. Section's `id` prop must match. */
|
||||
onJump?: (id: string) => void;
|
||||
/** Optional: container element to observe for scroll-spy. Defaults to scrollable ancestor. */
|
||||
observeRoot?: HTMLElement | null;
|
||||
header?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SectionRail - Anchor nav for single-entity Config pages.
|
||||
*
|
||||
* Replaces ListPane on pages that configure ONE entity (codex, copilot, cursor, droid…).
|
||||
* Each item maps to a <FormSection id="…"> in the FormPane. Scroll-spy via
|
||||
* IntersectionObserver auto-highlights the active section.
|
||||
*
|
||||
* Click → smooth-scrolls the matching FormSection into view.
|
||||
*/
|
||||
export function SectionRail({
|
||||
sections,
|
||||
onJump,
|
||||
observeRoot,
|
||||
header,
|
||||
className,
|
||||
}: SectionRailProps) {
|
||||
const [activeId, setActiveId] = useState<string>(sections[0]?.id ?? '');
|
||||
|
||||
// Scroll-spy: observe each FormSection element by id
|
||||
useEffect(() => {
|
||||
const els = sections
|
||||
.map((s) => document.getElementById(s.id))
|
||||
.filter((el): el is HTMLElement => el !== null);
|
||||
if (els.length === 0) return;
|
||||
|
||||
// FormPane wraps its body in shadcn ScrollArea (radix), which means the
|
||||
// actual scrolling element is an ancestor with overflow-y:auto/scroll —
|
||||
// NOT the page viewport. Walk up from the first FormSection to find it.
|
||||
// Without this, IntersectionObserver watches the wrong scroller and the
|
||||
// active section never updates as the form scrolls.
|
||||
const root = observeRoot ?? findScrollableAncestor(els[0]);
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
// Pick the section closest to top that's intersecting
|
||||
const visible = entries
|
||||
.filter((e) => e.isIntersecting)
|
||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
|
||||
if (visible[0]) setActiveId(visible[0].target.id);
|
||||
},
|
||||
{
|
||||
root,
|
||||
rootMargin: '-20% 0px -60% 0px',
|
||||
threshold: 0,
|
||||
}
|
||||
);
|
||||
els.forEach((el) => observer.observe(el));
|
||||
return () => observer.disconnect();
|
||||
}, [sections, observeRoot]);
|
||||
|
||||
const handleJump = (id: string) => {
|
||||
setActiveId(id);
|
||||
if (onJump) {
|
||||
onJump(id);
|
||||
return;
|
||||
}
|
||||
// scrollIntoView walks ancestors — works whether the scroller is the
|
||||
// viewport or the FormPane's internal ScrollArea.
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', className)}>
|
||||
{header && <div className="border-b p-3">{header}</div>}
|
||||
<ScrollArea className="flex-1">
|
||||
<ul className="p-2">
|
||||
{sections.map((s) => {
|
||||
const active = s.id === activeId;
|
||||
return (
|
||||
<li key={s.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleJump(s.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors',
|
||||
active
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block size-1.5 shrink-0 rounded-full transition-colors',
|
||||
active ? 'bg-accent-foreground' : 'bg-muted-foreground/30'
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{s.label}</span>
|
||||
{s.badge && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{s.badge}</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up the DOM until we find an element whose computed style scrolls vertically.
|
||||
* Returns null if none found (IntersectionObserver treats null as the viewport).
|
||||
*/
|
||||
function findScrollableAncestor(el: HTMLElement | null): HTMLElement | null {
|
||||
let node: HTMLElement | null = el?.parentElement ?? null;
|
||||
while (node && node !== document.body) {
|
||||
const style = window.getComputedStyle(node);
|
||||
const overflowY = style.overflowY;
|
||||
if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') {
|
||||
return node;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { MonitorLayout } from './monitor-layout';
|
||||
export { KpiRow, KpiCard } from './kpi';
|
||||
export { MonitorGrid, MonitorCard } from './monitor-grid';
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface KpiRowProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* KpiRow - Container row for hero stat tiles.
|
||||
*
|
||||
* Use only when there are ≤4 hero numbers. More than 4 → consider grouping into a card grid.
|
||||
* Responsive: 2 cols on mobile, 4 on desktop.
|
||||
*/
|
||||
export function KpiRow({ children, className }: KpiRowProps) {
|
||||
return (
|
||||
<div className={cn('grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface KpiCardProps {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
hint?: ReactNode;
|
||||
/** Tone for the hint line (e.g. positive delta, warning, etc.). */
|
||||
tone?: 'default' | 'positive' | 'warning' | 'negative';
|
||||
icon?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TONE_CLASSES: Record<NonNullable<KpiCardProps['tone']>, string> = {
|
||||
default: 'text-muted-foreground',
|
||||
positive: 'text-emerald-600 dark:text-emerald-400',
|
||||
warning: 'text-amber-600 dark:text-amber-400',
|
||||
negative: 'text-destructive',
|
||||
};
|
||||
|
||||
/**
|
||||
* KpiCard - Single hero stat tile inside a KpiRow.
|
||||
*/
|
||||
export function KpiCard({ label, value, hint, tone = 'default', icon, className }: KpiCardProps) {
|
||||
return (
|
||||
<div className={cn('rounded-xl border bg-card p-4 shadow-sm', className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
{icon && <span className="text-muted-foreground">{icon}</span>}
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-semibold tracking-tight tabular-nums">{value}</p>
|
||||
{hint && <p className={cn('mt-1 text-xs', TONE_CLASSES[tone])}>{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MonitorGridProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MonitorGrid - 12-column responsive grid for MonitorCards.
|
||||
*
|
||||
* - <640px: single column
|
||||
* - <1024px: 6 cols
|
||||
* - >=1024px: 12 cols
|
||||
*
|
||||
* Children should be <MonitorCard span={…}/> using span values 1-12.
|
||||
*/
|
||||
export function MonitorGrid({ children, className }: MonitorGridProps) {
|
||||
return (
|
||||
<div className={cn('grid grid-cols-1 gap-4 sm:grid-cols-6 lg:grid-cols-12', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Span = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||
type Variant = 'default' | 'terminal';
|
||||
|
||||
interface MonitorCardProps {
|
||||
/** Column span in MonitorGrid (1-12). Default 4. */
|
||||
span?: Span;
|
||||
variant?: Variant;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
/** Trailing actions in card header. */
|
||||
actions?: ReactNode;
|
||||
/** Optional meta line (e.g. timeframe, count) shown next to actions. */
|
||||
meta?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Tailwind needs literal class names — map span -> class explicitly so JIT picks them up.
|
||||
const SPAN_DESKTOP: Record<Span, string> = {
|
||||
1: 'lg:col-span-1',
|
||||
2: 'lg:col-span-2',
|
||||
3: 'lg:col-span-3',
|
||||
4: 'lg:col-span-4',
|
||||
5: 'lg:col-span-5',
|
||||
6: 'lg:col-span-6',
|
||||
7: 'lg:col-span-7',
|
||||
8: 'lg:col-span-8',
|
||||
9: 'lg:col-span-9',
|
||||
10: 'lg:col-span-10',
|
||||
11: 'lg:col-span-11',
|
||||
12: 'lg:col-span-12',
|
||||
};
|
||||
|
||||
// Tablet: clamp span to 6 cols max.
|
||||
const SPAN_TABLET: Record<Span, string> = {
|
||||
1: 'sm:col-span-2',
|
||||
2: 'sm:col-span-2',
|
||||
3: 'sm:col-span-3',
|
||||
4: 'sm:col-span-3',
|
||||
5: 'sm:col-span-3',
|
||||
6: 'sm:col-span-6',
|
||||
7: 'sm:col-span-6',
|
||||
8: 'sm:col-span-6',
|
||||
9: 'sm:col-span-6',
|
||||
10: 'sm:col-span-6',
|
||||
11: 'sm:col-span-6',
|
||||
12: 'sm:col-span-6',
|
||||
};
|
||||
|
||||
const VARIANT_CLASSES: Record<Variant, string> = {
|
||||
default: 'bg-card text-card-foreground',
|
||||
terminal: 'bg-zinc-950 text-emerald-400 border-emerald-900/40 font-mono',
|
||||
};
|
||||
|
||||
/**
|
||||
* MonitorCard - Single tile in a MonitorGrid.
|
||||
*
|
||||
* Variants:
|
||||
* - default: standard card
|
||||
* - terminal: dark monospace card for "live log" / health-style tiles
|
||||
*/
|
||||
export function MonitorCard({
|
||||
span = 4,
|
||||
variant = 'default',
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
meta,
|
||||
children,
|
||||
className,
|
||||
}: MonitorCardProps) {
|
||||
return (
|
||||
<article
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
'col-span-1 flex flex-col gap-3 rounded-xl border p-4 shadow-sm',
|
||||
SPAN_TABLET[span],
|
||||
SPAN_DESKTOP[span],
|
||||
VARIANT_CLASSES[variant],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{(title || actions || meta) && (
|
||||
<header className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
{title && (
|
||||
<h3
|
||||
className={cn(
|
||||
'truncate text-sm font-semibold',
|
||||
variant === 'terminal' && 'text-emerald-300'
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{description && (
|
||||
<p
|
||||
className={cn(
|
||||
'mt-0.5 text-xs',
|
||||
variant === 'terminal' ? 'text-emerald-500/70' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{meta && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs',
|
||||
variant === 'terminal' ? 'text-emerald-500/70' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{meta}
|
||||
</span>
|
||||
)}
|
||||
{actions}
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MonitorLayoutProps {
|
||||
/** Optional KPI row above the grid (use <KpiRow>). Render only when ≤4 hero numbers. */
|
||||
kpis?: ReactNode;
|
||||
/** Page body — typically a <MonitorGrid> of <MonitorCard>s. */
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MonitorLayout - Body wrapper for Monitor archetype pages (home, analytics, health, logs).
|
||||
*
|
||||
* - Optional KPI row pinned above the grid
|
||||
* - Scrollable body
|
||||
*
|
||||
* Compose inside <PageShell>:
|
||||
* <PageShell>
|
||||
* <PageHeader …/>
|
||||
* <MonitorLayout kpis={<KpiRow>…</KpiRow>}>
|
||||
* <MonitorGrid>…</MonitorGrid>
|
||||
* </MonitorLayout>
|
||||
* </PageShell>
|
||||
*/
|
||||
export function MonitorLayout({ kpis, children, className }: MonitorLayoutProps) {
|
||||
// Wrapper establishes its own flex column with min-h-0 so the ScrollArea
|
||||
// gets a definite height and can scroll its content. Without this we'd be
|
||||
// implicitly relying on PageShell being flex-col, which is fragile.
|
||||
return (
|
||||
<div className={cn('flex min-h-0 flex-1 flex-col', className)}>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-4 p-4 sm:p-6">
|
||||
{kpis}
|
||||
{children}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: LucideIcon;
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* EmptyState - Standard placeholder when a list / page has no content.
|
||||
*/
|
||||
export function EmptyState({ icon: Icon, title, description, action, className }: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{Icon && (
|
||||
<div className="rounded-full bg-muted p-3 text-muted-foreground">
|
||||
<Icon className="size-6" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
{description && <p className="mt-1 text-xs text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
{action && <div className="mt-2">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ErrorStateProps {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ErrorState - Standard error placeholder.
|
||||
*/
|
||||
export function ErrorState({ title, description, action, className }: ErrorStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-8 text-center',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="rounded-full bg-destructive/10 p-3 text-destructive">
|
||||
<AlertTriangle className="size-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-destructive">{title}</h3>
|
||||
{description && <p className="mt-1 text-xs text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
{action && <div className="mt-2">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { PageShell } from './page-shell';
|
||||
export { PageHeader } from './page-header';
|
||||
export { EmptyState } from './empty-state';
|
||||
export { ErrorState } from './error-state';
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
status?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PageHeader - Standard header strip for every page.
|
||||
*
|
||||
* Slots:
|
||||
* - title: page name (required)
|
||||
* - description: short subtitle below title
|
||||
* - status: status badge / chip on the trailing side
|
||||
* - actions: button group on the trailing side
|
||||
*
|
||||
* Layout: title block on left, status + actions on right.
|
||||
*/
|
||||
export function PageHeader({ title, description, status, actions, className }: PageHeaderProps) {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'flex flex-col gap-3 border-b bg-background/50 px-6 py-4 sm:flex-row sm:items-center sm:justify-between',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
|
||||
{status && <div className="flex items-center gap-2">{status}</div>}
|
||||
</div>
|
||||
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface PageShellProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PageShell - Outer wrapper for every dashboard page.
|
||||
*
|
||||
* Provides:
|
||||
* - Consistent max-width and padding
|
||||
* - Vertical flex layout for header + body
|
||||
*
|
||||
* Compose with PageHeader + (ConfigLayout | MonitorLayout) inside.
|
||||
*/
|
||||
export function PageShell({ children, className }: PageShellProps) {
|
||||
return <div className={cn('flex h-full min-h-0 w-full flex-col', className)}>{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* /_styleguide — DEV-ONLY route showcasing the CCS design system.
|
||||
*
|
||||
* Renders every primitive in isolation plus composed Config + Monitor archetype demos.
|
||||
* Gated by import.meta.env.DEV in App.tsx — never exposed in production builds.
|
||||
*
|
||||
* All demo data is anonymized (Provider A/B/C, fake metrics) so screenshots are
|
||||
* safe to publish in PRs without enabling Privacy mode.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { Activity, Bot, Cloud, Cpu, Plus, RefreshCcw, ShieldCheck, Zap } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { PageShell, PageHeader, EmptyState, ErrorState } from '@/components/page-shell';
|
||||
import {
|
||||
ConfigLayout,
|
||||
ListPane,
|
||||
SectionRail,
|
||||
FormPane,
|
||||
FormSection,
|
||||
JsonPane,
|
||||
type ListPaneItem,
|
||||
type SectionRailItem,
|
||||
} from '@/components/config-layout';
|
||||
import {
|
||||
MonitorLayout,
|
||||
MonitorGrid,
|
||||
MonitorCard,
|
||||
KpiRow,
|
||||
KpiCard,
|
||||
} from '@/components/monitor-layout';
|
||||
|
||||
const DEMO_PROVIDERS: ListPaneItem[] = [
|
||||
{ id: 'provider-a', label: 'Provider A', badge: '14', icon: <Zap className="size-3.5" /> },
|
||||
{ id: 'provider-b', label: 'Provider B', badge: '3', icon: <Bot className="size-3.5" /> },
|
||||
{ id: 'provider-c', label: 'Provider C', badge: '70', icon: <Cloud className="size-3.5" /> },
|
||||
{ id: 'provider-d', label: 'Provider D', badge: '1', icon: <ShieldCheck className="size-3.5" /> },
|
||||
];
|
||||
|
||||
const DEMO_SECTIONS: SectionRailItem[] = [
|
||||
{ id: 'general', label: 'General' },
|
||||
{ id: 'auth', label: 'Authentication' },
|
||||
{ id: 'routing', label: 'Routing' },
|
||||
{ id: 'models', label: 'Models' },
|
||||
{ id: 'tools', label: 'Tools & MCP' },
|
||||
{ id: 'advanced', label: 'Advanced' },
|
||||
];
|
||||
|
||||
const DEMO_CONFIG = {
|
||||
endpoint: 'https://example.local:8317',
|
||||
strategy: 'weighted-round-robin',
|
||||
accounts: 14,
|
||||
failover: ['provider-b', 'provider-c'],
|
||||
timeout_ms: 30000,
|
||||
};
|
||||
|
||||
export function StyleguidePage() {
|
||||
return (
|
||||
<div className="space-y-12 bg-muted/20 px-4 py-8 sm:px-8">
|
||||
<Intro />
|
||||
<PrimitiveSection title="1. PageShell + PageHeader" anchor="page-shell">
|
||||
<DemoPageHeader />
|
||||
</PrimitiveSection>
|
||||
|
||||
<PrimitiveSection
|
||||
title="2a. Config archetype — multi-entity (ListPane)"
|
||||
anchor="config-multi"
|
||||
>
|
||||
<DemoConfigMulti />
|
||||
</PrimitiveSection>
|
||||
|
||||
<PrimitiveSection
|
||||
title="2b. Config archetype — single-entity (SectionRail)"
|
||||
anchor="config-single"
|
||||
>
|
||||
<DemoConfigSingle />
|
||||
</PrimitiveSection>
|
||||
|
||||
<PrimitiveSection title="3. Monitor archetype" anchor="monitor">
|
||||
<DemoMonitor />
|
||||
</PrimitiveSection>
|
||||
|
||||
<PrimitiveSection title="4. EmptyState / ErrorState" anchor="states">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<EmptyState
|
||||
icon={Activity}
|
||||
title="No providers yet"
|
||||
description="Add your first provider to start routing requests."
|
||||
action={
|
||||
<Button size="sm">
|
||||
<Plus className="size-3.5" /> Add provider
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<ErrorState
|
||||
title="Failed to load configuration"
|
||||
description="The remote host returned 503. Retry in a moment."
|
||||
action={
|
||||
<Button size="sm" variant="outline">
|
||||
<RefreshCcw className="size-3.5" /> Retry
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</PrimitiveSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Section helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function Intro() {
|
||||
return (
|
||||
<header className="mx-auto max-w-4xl space-y-3 text-center">
|
||||
<Badge variant="outline" className="font-mono">
|
||||
DEV ONLY · /_styleguide
|
||||
</Badge>
|
||||
<h1 className="text-3xl font-bold tracking-tight">CCS Dashboard Design System</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Two archetypes — <strong>Config</strong> (3-pane: list | form | JSON) and{' '}
|
||||
<strong>Monitor</strong> (KPI row + grid) — wrapped by <code>PageShell</code>. Every page
|
||||
picks one archetype.
|
||||
</p>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function PrimitiveSection({
|
||||
title,
|
||||
anchor,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
anchor: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section id={anchor} className="mx-auto max-w-7xl space-y-3">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<div className="overflow-hidden rounded-2xl border bg-background shadow-sm">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// PageHeader demo
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function DemoPageHeader() {
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Demo Page"
|
||||
description="PageShell + PageHeader provide consistent chrome for every page."
|
||||
status={<Badge variant="secondary">Running</Badge>}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm">
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm">
|
||||
<Plus className="size-3.5" /> New
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="p-6 text-sm text-muted-foreground">Page body renders below the header.</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Config (multi-entity) demo
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function DemoConfigMulti() {
|
||||
const [selectedId, setSelectedId] = useState<string>('provider-a');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filtered = DEMO_PROVIDERS.filter((p) =>
|
||||
String(p.label).toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-[640px] flex-col">
|
||||
<PageHeader
|
||||
title="CLIProxy"
|
||||
status={<Badge variant="secondary">Multi-entity demo</Badge>}
|
||||
actions={
|
||||
<Button size="sm">
|
||||
<Plus className="size-3.5" /> New provider
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<ConfigLayout
|
||||
left={
|
||||
<ListPane
|
||||
items={filtered}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
searchValue={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Search providers…"
|
||||
footer={
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
<Plus className="size-3.5" /> Add provider
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
form={
|
||||
<FormPane
|
||||
header={
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Provider A</p>
|
||||
<p className="text-xs text-muted-foreground">14 accounts · synced 2m ago</p>
|
||||
</div>
|
||||
<Badge variant="outline">connected</Badge>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button size="sm">Save</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
Test connection
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FormSection id="general" title="General" description="Endpoint and routing strategy.">
|
||||
<Field label="Display name" defaultValue="Provider A" />
|
||||
<Field label="Endpoint" defaultValue="https://example.local:8317" />
|
||||
</FormSection>
|
||||
<FormSection id="auth" title="Authentication">
|
||||
<Field label="Strategy" defaultValue="weighted-round-robin" />
|
||||
</FormSection>
|
||||
</FormPane>
|
||||
}
|
||||
json={<JsonPane title="Effective" data={DEMO_CONFIG} />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Config (single-entity) demo
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function DemoConfigSingle() {
|
||||
return (
|
||||
<div className="flex h-[680px] flex-col">
|
||||
<PageHeader
|
||||
title="Cursor"
|
||||
description="Single-entity config with SectionRail."
|
||||
status={<Badge variant="secondary">v0.42 · connected</Badge>}
|
||||
actions={
|
||||
<Button size="sm">
|
||||
<Plus className="size-3.5" /> Open editor
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<ConfigLayout
|
||||
left={<SectionRail sections={DEMO_SECTIONS} />}
|
||||
form={
|
||||
<FormPane footer={<Button size="sm">Save configuration</Button>}>
|
||||
<FormSection id="general" title="General" description="Top-level identity.">
|
||||
<Field label="Endpoint" defaultValue="https://example.local:8317" />
|
||||
<Field label="Default profile" defaultValue="example-profile" />
|
||||
</FormSection>
|
||||
<FormSection id="auth" title="Authentication">
|
||||
<Field label="Strategy" defaultValue="oauth" />
|
||||
</FormSection>
|
||||
<FormSection id="routing" title="Routing">
|
||||
<Field label="Strategy" defaultValue="weighted-round-robin" />
|
||||
<Field label="Failover chain" defaultValue="provider-b → provider-c" />
|
||||
</FormSection>
|
||||
<FormSection id="models" title="Models">
|
||||
<Field label="Default model" defaultValue="model-x" />
|
||||
</FormSection>
|
||||
<FormSection id="tools" title="Tools & MCP">
|
||||
<Field label="MCP endpoint" defaultValue="(none)" />
|
||||
</FormSection>
|
||||
<FormSection id="advanced" title="Advanced">
|
||||
<Field label="Timeout (ms)" defaultValue="30000" />
|
||||
</FormSection>
|
||||
</FormPane>
|
||||
}
|
||||
json={
|
||||
<JsonPane
|
||||
title="Configuration"
|
||||
tabs={[
|
||||
{ id: 'effective', label: 'Effective', data: DEMO_CONFIG },
|
||||
{ id: 'override', label: 'Override', data: { strategy: 'failover-only' } },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Monitor demo
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function DemoMonitor() {
|
||||
return (
|
||||
<div className="flex h-[720px] flex-col">
|
||||
<PageHeader
|
||||
title="Home"
|
||||
status={<Badge variant="secondary">All systems nominal</Badge>}
|
||||
actions={
|
||||
<Button size="sm" variant="outline">
|
||||
<RefreshCcw className="size-3.5" /> Refresh
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<MonitorLayout
|
||||
kpis={
|
||||
<KpiRow>
|
||||
<KpiCard
|
||||
label="Active accounts"
|
||||
value="87"
|
||||
hint="▲ 3 vs yesterday"
|
||||
tone="positive"
|
||||
icon={<ShieldCheck className="size-4" />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Requests / 24h"
|
||||
value="12,481"
|
||||
hint="▲ 6.4%"
|
||||
tone="positive"
|
||||
icon={<Activity className="size-4" />}
|
||||
/>
|
||||
<KpiCard label="Errors" value="12" hint="3 quota, 9 transient" tone="warning" />
|
||||
<KpiCard
|
||||
label="Uptime"
|
||||
value="99.98%"
|
||||
hint="30-day"
|
||||
icon={<Cpu className="size-4" />}
|
||||
/>
|
||||
</KpiRow>
|
||||
}
|
||||
>
|
||||
<MonitorGrid>
|
||||
<MonitorCard
|
||||
span={8}
|
||||
title="Live account monitor"
|
||||
meta="realtime"
|
||||
description="Anonymized — Account 1, 2, …"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{[42, 58, 31, 81, 12, 96, 24, 19].map((pct, i) => (
|
||||
<div key={i} className="rounded-md border bg-muted/30 p-2">
|
||||
<p className="text-xs font-medium">Account {i + 1}</p>
|
||||
<p className="text-[10px] text-muted-foreground">tier · {pct}%</p>
|
||||
<div className="mt-1.5 h-1 overflow-hidden rounded bg-muted">
|
||||
<div
|
||||
className={
|
||||
pct > 80
|
||||
? 'h-full bg-destructive'
|
||||
: pct > 60
|
||||
? 'h-full bg-amber-500'
|
||||
: 'h-full bg-emerald-500'
|
||||
}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</MonitorCard>
|
||||
|
||||
<MonitorCard span={4} title="Top providers" meta="24h">
|
||||
<ul className="space-y-1.5 text-sm">
|
||||
{[
|
||||
{ name: 'Provider A', share: '62%' },
|
||||
{ name: 'Provider C', share: '24%' },
|
||||
{ name: 'Provider B', share: '9%' },
|
||||
{ name: 'Provider D', share: '5%' },
|
||||
].map((row) => (
|
||||
<li
|
||||
key={row.name}
|
||||
className="flex items-center justify-between rounded-md px-2 py-1.5 hover:bg-muted/50"
|
||||
>
|
||||
<span>{row.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{row.share}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</MonitorCard>
|
||||
|
||||
<MonitorCard span={6} title="Requests" meta="last 24h">
|
||||
<div className="flex h-32 items-end gap-1">
|
||||
{[14, 22, 30, 38, 28, 35, 42, 55, 48, 60, 70, 64, 78, 82, 90, 88, 95].map((h, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-t bg-accent/70"
|
||||
style={{ height: `${h}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</MonitorCard>
|
||||
|
||||
<MonitorCard span={6} variant="terminal" title="$ ccs health --watch" meta="live">
|
||||
<pre className="text-xs leading-relaxed">
|
||||
{`[OK] cliproxy :8317 uptime 14d 02h
|
||||
[OK] dashboard :3000 uptime 14d 02h
|
||||
[OK] qdrant :6333 uptime 21d 11h
|
||||
[OK] postgres :5432 uptime 47d 03h
|
||||
[!] ollama-gpu gpu 78% vram 9.4/12GB
|
||||
[OK] runner self-hosted online`}
|
||||
</pre>
|
||||
</MonitorCard>
|
||||
</MonitorGrid>
|
||||
</MonitorLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// tiny field helper
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function Field({ label, defaultValue }: { label: string; defaultValue: string }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</Label>
|
||||
<Input defaultValue={defaultValue} className="font-mono text-sm" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user