mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
feat(ui): add design system foundations
Introduce two locked page archetypes -- Config (3-pane) and Monitor (KPI row + 12-col grid) -- wrapped by shared PageShell + PageHeader. Primitives: - page-shell/: PageShell, PageHeader, EmptyState, ErrorState - config-layout/: ConfigLayout, ListPane, SectionRail, FormPane, FormSection, JsonPane (read-only by default, opt-in editable) - monitor-layout/: MonitorLayout, KpiRow, KpiCard, MonitorGrid, MonitorCard (incl. variant="terminal") Single ConfigLayout component, prop-controlled left rail: - ListPane for multi-entity (cliproxy, accounts, providers) - SectionRail for single-entity (codex, copilot, cursor, droid) with IntersectionObserver scroll-spy - omit for none DEV-ONLY /_styleguide route gated by import.meta.env.DEV. Shows every primitive plus composed Config (multi + single) and Monitor demos using fully anonymized data (Provider A/B/C, fake metrics). Phase 1 of dashboard design system unification. Existing pages untouched this phase -- migrations land in Phase 2+. Locked decisions in ui/docs/design-decisions.md: 1. In-app /_styleguide over Storybook 2. Archetype name: Monitor (not Dashboard) 3. JsonPane read-only by default 4. Health terminal aesthetic kept as variant 5. i18n per-page namespaces 6. SectionRail uses scroll-spy
This commit is contained in:
1 parent
eba53715e7
commit
64e78f6e69
20 files changed
+1513
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,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.
|
||||||
@@ -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 SharedPage = lazy(() => import('@/pages/shared').then((m) => ({ default: m.SharedPage })));
|
||||||
const UpdatesPage = lazy(() => import('@/pages/updates').then((m) => ({ default: m.UpdatesPage })));
|
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
|
// Loading fallback for lazy components
|
||||||
function PageLoader() {
|
function PageLoader() {
|
||||||
return (
|
return (
|
||||||
@@ -203,6 +208,16 @@ export default function App() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{StyleguidePage && (
|
||||||
|
<Route
|
||||||
|
path="/_styleguide"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<StyleguidePage />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Desktop: 3-pane grid */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'hidden min-h-0 flex-1 lg:grid lg:gap-4 lg:p-4',
|
||||||
|
left && json && 'lg:grid-cols-[260px_minmax(0,1fr)_360px]',
|
||||||
|
left && !json && 'lg:grid-cols-[260px_minmax(0,1fr)]',
|
||||||
|
!left && json && 'lg:grid-cols-[minmax(0,1fr)_360px]',
|
||||||
|
!left && !json && 'lg: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>
|
||||||
|
|
||||||
|
{/* Mobile/tablet: tabs */}
|
||||||
|
<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 [active, setActive] = useState(tabs[0]?.id ?? 'form');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex min-h-0 flex-1 flex-col gap-3 p-3 lg:hidden', className)}>
|
||||||
|
<Tabs value={active} onValueChange={setActive}>
|
||||||
|
<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,114 @@
|
|||||||
|
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 [activeTab, setActiveTab] = useState<string>(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={setActiveTab}
|
||||||
|
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) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
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,110 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
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: observeRoot ?? null,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,36 @@
|
|||||||
|
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) {
|
||||||
|
return (
|
||||||
|
<ScrollArea className={cn('flex-1', className)}>
|
||||||
|
<div className="space-y-4 p-4 sm:p-6">
|
||||||
|
{kpis}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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