refactor(ui): revert home/cliproxy migrations and restructure design system

User feedback caught a Phase 2 mistake: forcing home and cliproxy into
a one-size-fits-all PageShell + PageHeader chrome regressed density on
both pages.

- home: original 1-row hero (logo + title + version + 4 inline stats)
  was split into PageHeader + KpiRow, doubling vertical footprint
- cliproxy: original rail-anchored brand was duplicated by a top
  PageHeader, stealing ~80px from the 3-pane body

Resolution: revert both pages to dev-branch state, then restructure
ui/docs/design-system.md to extract THREE identity-strip patterns from
the canonical references rather than imposing one:

  1. HeroBar       (1-row dense)             -> home pattern
  2. Rail-anchored (no top chrome)           -> cliproxy pattern
  3. PageHeader    (title + description)     -> health pattern

Health stays migrated -- the Monitor archetype + PageHeader is a real
improvement there because the gauge + KPI row + group cards don't fit
the HeroBar pattern.

Future page migrations adapt to whichever identity-strip pattern fits
the page's content shape, not the other way around. design-decisions.md
gets a v1.1 revision row capturing the rationale.
This commit is contained in:
Tam Nhu Tran
2026-04-25 13:34:43 -04:00
parent c59622fabd
commit 1a2fd848f0
9 changed files with 543 additions and 492 deletions
+21
View File
@@ -13,6 +13,27 @@ The 6 open questions from the brainstorm phase, resolved before implementation.
--- ---
## v1.1 revision (2026-04-25) — identity-strip patterns
Phase 2 attempted to migrate `home` and `cliproxy` to a one-size-fits-all `PageShell + PageHeader` chrome. Both regressed on density:
- **home** had a single-row hero (logo + title + version + 4 inline stats) — splitting it into a stacked PageHeader + KpiRow doubled vertical footprint and lost scannability
- **cliproxy** had identity in the left rail — adding a top PageHeader duplicated branding and stole ~80px from the 3-pane body
**Resolution:** the design system is restructured around three identity-strip patterns extracted from the existing canonical references:
| # | Pattern | Reference | When |
|---|---------|-----------|------|
| 7a | `HeroBar` (1-row dense) | `pages/home.tsx` | Dashboard pages with ≤4 hero stats |
| 7b | Rail-anchored identity (no top chrome) | `pages/cliproxy.tsx` | Multi-entity Config pages where rail carries brand |
| 7c | `PageHeader` (current) | `pages/health.tsx` | Pages where description / status info is non-redundant |
Phase 2's home + cliproxy migrations are reverted. Health stays migrated (Monitor archetype + PageHeader works there). Future page migrations adapt to whichever pattern fits, NOT the other way around.
**Why bottom-up:** the existing references already proved their patterns work in production. The job of the design system is to formalize what works, not impose what should.
---
## How to revisit ## 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. 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.
File diff suppressed because one or more lines are too long
+123 -124
View File
@@ -1,36 +1,102 @@
# CCS Dashboard Design System # 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`. A page-level design system extracted from the **canonical reference pages**`home` and `cliproxy` — that already prove the patterns work in production. New pages should adapt to these references, not the other way around.
Health is the second-tier reference for pages whose content shape (gauge + KPIs + group accordions) doesn't fit either canonical hero — it uses the `MonitorLayout` archetype.
> Live preview in dev: `bun run dev` then visit `/_styleguide`. > Live preview in dev: `bun run dev` then visit `/_styleguide`.
--- ---
## 1. The shell — every page ## 1. Identity-strip patterns (pick one per page)
Three patterns cover every page in the dashboard. The choice depends on what your page already has.
### 1a. `HeroBar` — single-row dense hero
**Canonical reference:** `pages/home.tsx`
``` ```
PageShell ┌────────────────────────────────────────────────────────────────────┐
├─ PageHeader (title · description · status · actions) │ [logo] Title [version] ┃ [Stat] [Stat] [Stat] [Stat] │
└─ <archetype body> └────────────────────────────────────────────────────────────────────┘
``` ```
```tsx One row packs logo + title + version + ≤4 inline stats. Optional subtle dotted-pattern background. Stats are clickable when they double as navigation entry points.
<PageShell>
<PageHeader title="Cliproxy" status={} actions={} /> **Use it when:**
<ConfigLayout /> {/* OR <MonitorLayout> */} - The page is a dashboard / monitor with a clear product identity
</PageShell> - ≤4 hero stats summarize the page in numbers
- Vertical real estate matters (this is half the height of a stacked PageHeader + KpiRow)
**Building blocks:**
- `<HeroSection version={…}/>` — logo + title + subtitle from `components/layout/hero-section.tsx`
- `<InlineStat title value icon variant onClick/>` — clickable stat tile (extracted from `home.tsx`); promote to a shared primitive when a 2nd page adopts it
### 1b. Rail-anchored identity — no top chrome
**Canonical reference:** `pages/cliproxy.tsx`
```
┌──────────┬─────────────────────────────────────────────────────────┐
│ ⚡ Brand │ │
│ subtitle │ │
│ [QSetup] │ full-height 3-pane body │
│ │ │
│ • prov A │ (form + raw json fill the entire viewport) │
│ • prov B │ │
│ … │ │
│ [status] │ │
└──────────┴─────────────────────────────────────────────────────────┘
``` ```
Header rules: Page identity (brand + page-level CTA + status) lives **inside the left rail**. Zero top chrome — the body archetype gets the full vertical viewport.
- `title` is mandatory; `description` optional 1-line subtitle.
- `status` slot: small badges/chips (e.g. "Running · :8317"). **Use it when:**
- `actions` slot: button group; **primary action belongs in FormPane footer**, not here, for Config pages. - The page is a multi-entity Config (3-pane: list / form / json)
- The rail naturally carries the page name (you'd duplicate it in a top header)
- Vertical real estate is at a premium because the body has dense form content
**Building blocks:**
- The left rail's own header section (in-place markup, no extracted primitive yet — keep it bespoke until a 2nd page adopts the pattern)
- Recommended order in the rail: brand strip → primary CTA → entity list → status widget → footer summary
### 1c. `PageHeader` — title-row chrome
**Canonical reference:** `pages/health.tsx`
```
┌────────────────────────────────────────────────────────────────────┐
│ Title [v-badge] [action] [action] │
│ Description / last-update / status info │
└────────────────────────────────────────────────────────────────────┘
```
Traditional title row with description and trailing actions.
**Use it when:**
- The page does NOT fit either canonical hero
- The description carries genuinely non-redundant context (last refresh, page hierarchy, filter state, version)
- Body archetype below benefits from a clear identity strip
**API:** `<PageHeader title description status actions />` — title + description on left, status badges + action buttons on right.
### Decision table
| Page shape | Identity strip |
|------------|---------------|
| Dashboard / overview with ≤4 hero stats | **HeroBar** (home pattern) |
| Multi-entity Config (3-pane: list/form/json) | **Rail-anchored** (cliproxy pattern, no top chrome) |
| Single-entity Config OR Monitor with a real hero viz | **PageHeader** + body archetype |
| Wizard / login / dialog | None — bespoke shell |
--- ---
## 2. Config archetype — 3-pane ## 2. Body archetypes
Used by every page that configures one or more entities (cliproxy, accounts, codex, copilot, cursor, droid, claude-extension, api, shared, profiles). ### 2a. Config — 3-pane
**Canonical reference:** `pages/cliproxy.tsx`
``` ```
┌──────────┬──────────────────┬──────────┐ ┌──────────┬──────────────────┬──────────┐
@@ -39,141 +105,74 @@ Used by every page that configures one or more entities (cliproxy, accounts, cod
└──────────┴──────────────────┴──────────┘ └──────────┴──────────────────┴──────────┘
``` ```
One component, prop-controlled left rail: Left rail = `ListPane` (multi-entity) or `SectionRail` (single-entity, with `IntersectionObserver` scroll-spy). Form and JSON panes are middle and right respectively.
```tsx ```tsx
// Multi-entity (cliproxy, accounts, providers)
<ConfigLayout <ConfigLayout
left={<ListPane items={} selectedId={id} onSelect={} />} left={<ListPane />} // multi-entity
// OR
left={<SectionRail />} // single-entity
form={<FormPane></FormPane>} form={<FormPane></FormPane>}
json={<JsonPane data={} />} 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 **Rules:**
- Save action lives **only** in `FormPane` footer
- `<1024px`: collapses to tabs (Browse | Configure | JSON)
- `JsonPane` is read-only by default; opt-in `editable` for cliproxy-style inline editing
- **Layout**: left 260px / form flex / json 360px on `>=1024px`. ### 2b. Monitor — KPI row + 12-col grid
- **<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 **Canonical reference:** `pages/health.tsx`
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) │ KpiRow (≤4 hero numbers) │
├──────────────────────────────────────────────── ├────────────────────────────────────────┤
│ MonitorGrid (12-col): │ MonitorGrid (12-col): │
│ <MonitorCard span={8}> primary viz │ <MonitorCard span={…}/>
│ <MonitorCard span={4}> side widget │ └────────────────────────────────────────┘
│ <MonitorCard span={6}> ... │
│ <MonitorCard span={6}> ... │
└────────────────────────────────────────────────┘
``` ```
```tsx ```tsx
<MonitorLayout <MonitorLayout kpis={<KpiRow></KpiRow>}>
kpis={
<KpiRow>
<KpiCard label="Active accounts" value="87" hint="▲ 3" tone="positive" />
</KpiRow>
}
>
<MonitorGrid> <MonitorGrid>
<MonitorCard span={8} title="Live monitor"></MonitorCard> <MonitorCard span={6} variant="terminal" title=></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> </MonitorGrid>
</MonitorLayout> </MonitorLayout>
``` ```
### Rules **Rules:**
- `KpiRow` only when ≤4 hero numbers; more → group inside the grid
- One primary viz per page, span ≥8 cols
- `variant="terminal"` for live-log / `health --watch` aesthetics
- **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. ## 3. Composing a new page
- **Spans clamp at 6 cols on tablet**, single column on mobile.
```tsx
// Example: a new dashboard-style page
<PageShell>
<HeroBar /> {/* or PageHeader, or rail-anchored identity */}
<MonitorLayout /> {/* or ConfigLayout */}
</PageShell>
```
Target LOC for a new page: **~80** for typical config, **~120** for monitor with hero strip. Target LOC for an outlier rewrite: **<400**.
--- ---
## 4. When NOT to use either archetype ## 4. When NOT to use either archetype
These remain bespoke: These remain bespoke and are out of scope:
- `/login` — minimal centered shell, no header/grid. - `/login` — minimal centered shell
- Setup wizard — modal overlay, not a page. - Setup wizard — modal overlay
- Dialogs — radix `Dialog`, not a layout. - Dialogs — Radix `Dialog`
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 ## 5. Decisions
The recipe for any new page is: See [`design-decisions.md`](./design-decisions.md) for the resolved open questions and the v1.1 revision rationale.
```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.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

+9 -3
View File
@@ -121,9 +121,15 @@ function Intro() {
</Badge> </Badge>
<h1 className="text-3xl font-bold tracking-tight">CCS Dashboard Design System</h1> <h1 className="text-3xl font-bold tracking-tight">CCS Dashboard Design System</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Two archetypes <strong>Config</strong> (3-pane: list | form | JSON) and{' '} Three identity-strip patterns <strong>HeroBar</strong> (one-row dense, see <code>/</code>{' '}
<strong>Monitor</strong> (KPI row + grid) wrapped by <code>PageShell</code>. Every page home page), <strong>rail-anchored</strong> (no top chrome, see <code>/cliproxy</code>), and{' '}
picks one archetype. <strong>PageHeader</strong> (title + description + actions, see <code>/health</code>) and
two body archetypes: <strong>Config</strong> (3-pane: list / form / JSON) and{' '}
<strong>Monitor</strong> (KPI row + grid). Pick the identity strip that matches your
page&apos;s shape; pick the body archetype your content needs.
</p>
<p className="text-xs text-muted-foreground">
See <code>ui/docs/design-system.md</code> for the decision table.
</p> </p>
</header> </header>
); );
+219 -225
View File
@@ -11,8 +11,6 @@ import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Check, X, RefreshCw, Sparkles, Zap, GitBranch, Trash2 } from 'lucide-react'; import { Check, X, RefreshCw, Sparkles, Zap, GitBranch, Trash2 } from 'lucide-react';
import { PageShell, PageHeader } from '@/components/page-shell';
import { ConfigLayout } from '@/components/config-layout';
import { QuickSetupWizard } from '@/components/quick-setup-wizard'; import { QuickSetupWizard } from '@/components/quick-setup-wizard';
import { AddAccountDialog } from '@/components/account/add-account-dialog'; import { AddAccountDialog } from '@/components/account/add-account-dialog';
import { AccountSafetyWarningCard } from '@/components/account/account-safety-warning-card'; import { AccountSafetyWarningCard } from '@/components/account/account-safety-warning-card';
@@ -354,238 +352,234 @@ export function CliproxyPage() {
setSelectedProvider(null); setSelectedProvider(null);
}; };
const sidebar = ( return (
<div className="flex h-full flex-col bg-muted/30"> <div className="flex h-full min-h-0 overflow-hidden">
{/* Header inside the rail: Quick Setup CTA */} {/* Left Sidebar */}
<div className="border-b bg-background p-3"> <div className="w-80 border-r flex flex-col bg-muted/30">
<Button {/* Header */}
variant="default" <div className="p-4 border-b bg-background">
size="sm" <div className="flex items-center justify-between mb-1">
className="w-full gap-2" <div className="flex items-center gap-2">
onClick={() => setWizardOpen(true)} <Zap className="w-5 h-5 text-primary" />
> <h1 className="font-semibold">{updateCheck?.backendLabel ?? 'CLIProxy'}</h1>
<Sparkles className="w-4 h-4" /> </div>
{t('cliproxyPage.quickSetup')} <Button
</Button> variant="ghost"
</div> size="icon"
className="h-8 w-8"
{/* Providers List */} onClick={handleRefresh}
<ScrollArea className="flex-1"> disabled={isFetching}
<div className="p-2"> >
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2"> <RefreshCw className={cn('w-4 h-4', isFetching && 'animate-spin')} />
{t('cliproxyPage.providers')} </Button>
</div> </div>
{authLoading ? ( <p className="text-xs text-muted-foreground mb-3">
<div className="space-y-2 px-2"> {t('cliproxyPage.accountManagement')}
{[1, 2, 3, 4].map((i) => ( </p>
<Skeleton key={i} className="h-14 w-full rounded-lg" />
))}
</div>
) : (
<div className="space-y-4">
{providerSections.map((section) => (
<div key={section.id} className="space-y-1">
<div className="px-3">
<div className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{t(section.labelKey)}
</div>
<p className="mt-1 text-[11px] leading-relaxed text-muted-foreground">
{t(section.hintKey)}
</p>
</div>
<div className="space-y-1">
{section.items.map((status) => (
<ProviderSidebarItem
key={status.provider}
status={status}
isSelected={effectiveProvider === status.provider}
onSelect={() => handleSelectProvider(status.provider)}
/>
))}
</div>
</div>
))}
</div>
)}
{/* Variants Section */} <Button
{variants.length > 0 && ( variant="default"
<> size="sm"
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2 mt-4 flex items-center gap-1.5"> className="w-full gap-2"
<GitBranch className="w-3 h-3" /> onClick={() => setWizardOpen(true)}
{t('cliproxyPage.variants')} >
</div> <Sparkles className="w-4 h-4" />
<div className="space-y-1"> {t('cliproxyPage.quickSetup')}
{variants.map((variant) => ( </Button>
<VariantSidebarItem </div>
key={variant.name}
variant={variant} {/* Providers List */}
parentAuth={providers.find((p) => p.provider === variant.provider)} <ScrollArea className="flex-1">
isSelected={selectedVariant === variant.name} <div className="p-2">
onSelect={() => handleSelectVariant(variant.name)} <div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2">
onDelete={() => deleteMutation.mutate(variant.name)} {t('cliproxyPage.providers')}
isDeleting={deleteMutation.isPending} </div>
/> {authLoading ? (
<div className="space-y-2 px-2">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-14 w-full rounded-lg" />
))} ))}
</div> </div>
</> ) : (
)} <div className="space-y-4">
{providerSections.map((section) => (
<div key={section.id} className="space-y-1">
<div className="px-3">
<div className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{t(section.labelKey)}
</div>
<p className="mt-1 text-[11px] leading-relaxed text-muted-foreground">
{t(section.hintKey)}
</p>
</div>
<div className="space-y-1">
{section.items.map((status) => (
<ProviderSidebarItem
key={status.provider}
status={status}
isSelected={effectiveProvider === status.provider}
onSelect={() => handleSelectProvider(status.provider)}
/>
))}
</div>
</div>
))}
</div>
)}
{/* Variants Section */}
{variants.length > 0 && (
<>
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2 mt-4 flex items-center gap-1.5">
<GitBranch className="w-3 h-3" />
{t('cliproxyPage.variants')}
</div>
<div className="space-y-1">
{variants.map((variant) => (
<VariantSidebarItem
key={variant.name}
variant={variant}
parentAuth={providers.find((p) => p.provider === variant.provider)}
isSelected={selectedVariant === variant.name}
onSelect={() => handleSelectVariant(variant.name)}
onDelete={() => deleteMutation.mutate(variant.name)}
isDeleting={deleteMutation.isPending}
/>
))}
</div>
</>
)}
</div>
</ScrollArea>
{/* Proxy Status Widget */}
<div className="p-3 border-t">
<ProxyStatusWidget />
</div> </div>
</ScrollArea>
{/* Proxy Status Widget */} {/* Footer Stats */}
<div className="border-t p-3"> <div className="p-3 border-t bg-background text-xs text-muted-foreground">
<ProxyStatusWidget /> <div className="flex items-center justify-between">
</div> <span>{t('cliproxyPage.providerCount', { count: providers.length })}</span>
<span className="flex items-center gap-1">
{/* Footer Stats */} <Check className="w-3 h-3 text-green-600" />
<div className="border-t bg-background p-3 text-xs text-muted-foreground"> {t('cliproxyPage.connectedCount', {
<div className="flex items-center justify-between"> count: providers.filter((p) => p.authenticated).length,
<span>{t('cliproxyPage.providerCount', { count: providers.length })}</span> })}
<span className="flex items-center gap-1"> </span>
<Check className="w-3 h-3 text-green-600" /> </div>
{t('cliproxyPage.connectedCount', {
count: providers.filter((p) => p.authenticated).length,
})}
</span>
</div> </div>
</div> </div>
</div>
);
const detail = ( {/* Right Panel */}
<div className="flex h-full min-w-0 flex-col overflow-hidden bg-background"> <div className="flex-1 flex min-w-0 flex-col overflow-hidden bg-background">
{selectedVariantData && parentAuthForVariant ? ( {selectedVariantData && parentAuthForVariant ? (
<> <>
<ProviderEditor <ProviderEditor
provider={selectedVariantData.name} provider={selectedVariantData.name}
displayName={t('cliproxyPage.variantDisplay', { displayName={t('cliproxyPage.variantDisplay', {
name: selectedVariantData.name, name: selectedVariantData.name,
provider: selectedVariantData.provider,
})}
authStatus={parentAuthForVariant}
catalog={catalogs[selectedVariantData.provider]}
routing={routingHints[selectedVariantData.provider]}
logoProvider={selectedVariantData.provider}
baseProvider={selectedVariantData.provider}
defaultTarget={selectedVariantData.target}
isRemoteMode={isRemoteMode}
port={selectedVariantData.port}
topNotice={
showAccountSafetyWarning ? (
<AccountSafetyWarningCard compact showProxySettingsLink />
) : undefined
}
onAddAccount={() =>
setAddAccountProvider({
provider: selectedVariantData.provider, provider: selectedVariantData.provider,
displayName: parentAuthForVariant.displayName, })}
isFirstAccount: (parentAuthForVariant.accounts?.length || 0) === 0, authStatus={parentAuthForVariant}
}) catalog={catalogs[selectedVariantData.provider]}
} routing={routingHints[selectedVariantData.provider]}
onSetDefault={(accountId) => logoProvider={selectedVariantData.provider}
setDefaultMutation.mutate({ baseProvider={selectedVariantData.provider}
provider: selectedVariantData.provider, defaultTarget={selectedVariantData.target}
accountId, isRemoteMode={isRemoteMode}
}) port={selectedVariantData.port}
} topNotice={
onRemoveAccount={(accountId) => showAccountSafetyWarning ? (
removeMutation.mutate({ <AccountSafetyWarningCard compact showProxySettingsLink />
provider: selectedVariantData.provider, ) : undefined
accountId, }
}) onAddAccount={() =>
} setAddAccountProvider({
onPauseToggle={(accountId, paused) => provider: selectedVariantData.provider,
handlePauseToggle(selectedVariantData.provider, accountId, paused) displayName: parentAuthForVariant.displayName,
} isFirstAccount: (parentAuthForVariant.accounts?.length || 0) === 0,
onSoloMode={(accountId) => handleSoloMode(selectedVariantData.provider, accountId)} })
onBulkPause={(accountIds) => handleBulkPause(selectedVariantData.provider, accountIds)} }
onBulkResume={(accountIds) => onSetDefault={(accountId) =>
handleBulkResume(selectedVariantData.provider, accountIds) setDefaultMutation.mutate({
} provider: selectedVariantData.provider,
isRemovingAccount={removeMutation.isPending} accountId,
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending} })
isSoloingAccount={soloMutation.isPending} }
isBulkPausing={bulkPauseMutation.isPending} onRemoveAccount={(accountId) =>
isBulkResuming={bulkResumeMutation.isPending} removeMutation.mutate({
/> provider: selectedVariantData.provider,
</> accountId,
) : selectedStatus ? ( })
<> }
<ProviderEditor onPauseToggle={(accountId, paused) =>
provider={selectedStatus.provider} handlePauseToggle(selectedVariantData.provider, accountId, paused)
displayName={selectedStatus.displayName} }
authStatus={selectedStatus} onSoloMode={(accountId) => handleSoloMode(selectedVariantData.provider, accountId)}
catalog={catalogs[selectedStatus.provider]} onBulkPause={(accountIds) =>
routing={routingHints[selectedStatus.provider]} handleBulkPause(selectedVariantData.provider, accountIds)
isRemoteMode={isRemoteMode} }
topNotice={ onBulkResume={(accountIds) =>
showAccountSafetyWarning ? ( handleBulkResume(selectedVariantData.provider, accountIds)
<AccountSafetyWarningCard compact showProxySettingsLink /> }
) : undefined isRemovingAccount={removeMutation.isPending}
} isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
onAddAccount={() => isSoloingAccount={soloMutation.isPending}
setAddAccountProvider({ isBulkPausing={bulkPauseMutation.isPending}
provider: selectedStatus.provider, isBulkResuming={bulkResumeMutation.isPending}
displayName: selectedStatus.displayName, />
isFirstAccount: (selectedStatus.accounts?.length || 0) === 0, </>
}) ) : selectedStatus ? (
} <>
onSetDefault={(accountId) => <ProviderEditor
setDefaultMutation.mutate({ provider={selectedStatus.provider}
provider: selectedStatus.provider, displayName={selectedStatus.displayName}
accountId, authStatus={selectedStatus}
}) catalog={catalogs[selectedStatus.provider]}
} routing={routingHints[selectedStatus.provider]}
onRemoveAccount={(accountId) => isRemoteMode={isRemoteMode}
removeMutation.mutate({ topNotice={
provider: selectedStatus.provider, showAccountSafetyWarning ? (
accountId, <AccountSafetyWarningCard compact showProxySettingsLink />
}) ) : undefined
} }
onPauseToggle={(accountId, paused) => onAddAccount={() =>
handlePauseToggle(selectedStatus.provider, accountId, paused) setAddAccountProvider({
} provider: selectedStatus.provider,
onSoloMode={(accountId) => handleSoloMode(selectedStatus.provider, accountId)} displayName: selectedStatus.displayName,
onBulkPause={(accountIds) => handleBulkPause(selectedStatus.provider, accountIds)} isFirstAccount: (selectedStatus.accounts?.length || 0) === 0,
onBulkResume={(accountIds) => handleBulkResume(selectedStatus.provider, accountIds)} })
isRemovingAccount={removeMutation.isPending} }
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending} onSetDefault={(accountId) =>
isSoloingAccount={soloMutation.isPending} setDefaultMutation.mutate({
isBulkPausing={bulkPauseMutation.isPending} provider: selectedStatus.provider,
isBulkResuming={bulkResumeMutation.isPending} accountId,
/> })
</> }
) : ( onRemoveAccount={(accountId) =>
<EmptyProviderState onSetup={() => setWizardOpen(true)} /> removeMutation.mutate({
)} provider: selectedStatus.provider,
</div> accountId,
); })
}
return ( onPauseToggle={(accountId, paused) =>
<PageShell> handlePauseToggle(selectedStatus.provider, accountId, paused)
<PageHeader }
title={ onSoloMode={(accountId) => handleSoloMode(selectedStatus.provider, accountId)}
<span className="flex items-center gap-2"> onBulkPause={(accountIds) => handleBulkPause(selectedStatus.provider, accountIds)}
<Zap className="w-5 h-5 text-primary" /> onBulkResume={(accountIds) => handleBulkResume(selectedStatus.provider, accountIds)}
{updateCheck?.backendLabel ?? 'CLIProxy'} isRemovingAccount={removeMutation.isPending}
</span> isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
} isSoloingAccount={soloMutation.isPending}
description={t('cliproxyPage.accountManagement')} isBulkPausing={bulkPauseMutation.isPending}
actions={ isBulkResuming={bulkResumeMutation.isPending}
<Button />
variant="ghost" </>
size="icon" ) : (
className="h-8 w-8" <EmptyProviderState onSetup={() => setWizardOpen(true)} />
onClick={handleRefresh} )}
disabled={isFetching} </div>
aria-label="Refresh"
>
<RefreshCw className={cn('w-4 h-4', isFetching && 'animate-spin')} />
</Button>
}
/>
<ConfigLayout left={sidebar} form={detail} />
{/* Dialogs */} {/* Dialogs */}
<QuickSetupWizard open={wizardOpen} onClose={() => setWizardOpen(false)} /> <QuickSetupWizard open={wizardOpen} onClose={() => setWizardOpen(false)} />
@@ -601,6 +595,6 @@ export function CliproxyPage() {
} }
isFirstAccount={addAccountProvider?.isFirstAccount || false} isFirstAccount={addAccountProvider?.isFirstAccount || false}
/> />
</PageShell> </div>
); );
} }
+168 -137
View File
@@ -1,38 +1,68 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { HeroSection } from '@/components/layout/hero-section';
import {
ActivityIcon,
AlertTriangle,
ArrowRight,
KeyIcon,
ScrollText,
ShieldCheck,
UsersIcon,
Zap,
} from 'lucide-react';
import { AuthMonitor } from '@/components/monitoring/auth-monitor'; import { AuthMonitor } from '@/components/monitoring/auth-monitor';
import { ErrorLogsMonitor } from '@/components/error-logs-monitor'; import { ErrorLogsMonitor } from '@/components/error-logs-monitor';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Key, Zap, Users, Activity, AlertTriangle, ArrowRight, ScrollText } from 'lucide-react';
import { useOverview } from '@/hooks/use-overview'; import { useOverview } from '@/hooks/use-overview';
import { useSharedSummary } from '@/hooks/use-shared'; import { useSharedSummary } from '@/hooks/use-shared';
import { PageShell, PageHeader } from '@/components/page-shell'; import { cn } from '@/lib/utils';
import { import type { LucideIcon } from 'lucide-react';
MonitorLayout, import { useTranslation } from 'react-i18next';
KpiRow,
KpiCard,
MonitorGrid,
MonitorCard,
} from '@/components/monitor-layout';
const HEALTH_TONE = { const HEALTH_VARIANTS = {
ok: 'positive', ok: 'success',
warning: 'warning', warning: 'warning',
error: 'negative', error: 'error',
} as const; } as const;
type StatVariant = 'default' | 'success' | 'warning' | 'error' | 'accent';
const variantStyles: Record<StatVariant, { iconBg: string; iconColor: string }> = {
default: { iconBg: 'bg-muted', iconColor: 'text-muted-foreground' },
success: { iconBg: 'bg-green-600/15', iconColor: 'text-green-700 dark:text-green-500' },
warning: { iconBg: 'bg-amber-500/15', iconColor: 'text-amber-700 dark:text-amber-400' },
error: { iconBg: 'bg-red-600/15', iconColor: 'text-red-700 dark:text-red-500' },
accent: { iconBg: 'bg-accent/15', iconColor: 'text-accent' },
};
function InlineStat({
title,
value,
icon: Icon,
variant = 'default',
onClick,
}: {
title: string;
value: number | string;
icon: LucideIcon;
variant?: StatVariant;
onClick?: () => void;
}) {
const styles = variantStyles[variant];
return (
<button
onClick={onClick}
className={cn(
'flex items-center gap-3 px-4 py-2.5 rounded-lg border bg-card/50',
'transition-all hover:bg-card hover:shadow-sm hover:-translate-y-0.5',
'active:scale-[0.98]'
)}
>
<div className={cn('flex items-center justify-center w-9 h-9 rounded-md', styles.iconBg)}>
<Icon className={cn('w-4 h-4', styles.iconColor)} />
</div>
<div className="text-left">
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">{title}</p>
<p className={cn('text-lg font-bold font-mono leading-tight', styles.iconColor)}>{value}</p>
</div>
</button>
);
}
export function HomePage() { export function HomePage() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -40,138 +70,139 @@ export function HomePage() {
const { data: shared, isLoading: isSharedLoading } = useSharedSummary(); const { data: shared, isLoading: isSharedLoading } = useSharedSummary();
if (isOverviewLoading || isSharedLoading) { if (isOverviewLoading || isSharedLoading) {
return <HomeLoadingSkeleton />; return (
<div className="p-6 space-y-6">
{/* Hero Row Skeleton */}
<div className="rounded-xl border p-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<Skeleton className="h-12 w-12 rounded-lg" />
<div>
<Skeleton className="h-7 w-[180px] mb-2" />
<Skeleton className="h-4 w-[220px]" />
</div>
</div>
<div className="flex items-center gap-3">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-14 w-28 rounded-lg" />
))}
</div>
</div>
{/* Auth Monitor Skeleton */}
<div className="border rounded-xl overflow-hidden">
<div className="px-4 py-2.5 border-b flex items-center justify-between">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-20" />
</div>
<div className="px-4 py-3 border-b">
<Skeleton className="h-2 w-full rounded-full" />
</div>
{[1, 2, 3].map((i) => (
<div key={i} className="px-4 py-2.5 flex items-center gap-3 border-b last:border-b-0">
<Skeleton className="w-2.5 h-2.5 rounded-full" />
<Skeleton className="h-4 flex-1" />
<Skeleton className="h-1.5 w-24 rounded-full" />
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
</div>
);
} }
const healthTone = overview?.health const healthVariant = overview?.health
? HEALTH_TONE[overview.health.status as keyof typeof HEALTH_TONE] ? HEALTH_VARIANTS[overview.health.status as keyof typeof HEALTH_VARIANTS]
: 'default'; : undefined;
return ( return (
<PageShell> <div className="p-6 space-y-6">
<PageHeader {/* Hero Row: Logo/Title + Inline Stats */}
title={t('heroSection.title')} <div className="relative overflow-hidden rounded-xl border bg-gradient-to-br from-background via-background to-muted/30">
description={t('heroSection.subtitle')} {/* Subtle background pattern */}
status={ <div className="absolute inset-0 opacity-[0.03] pointer-events-none">
overview?.version && ( <div
<Badge variant="outline" className="font-mono text-xs"> className="absolute inset-0"
v{overview.version} style={{
</Badge> backgroundImage: `radial-gradient(circle at 1px 1px, currentColor 1px, transparent 0)`,
) backgroundSize: '24px 24px',
} }}
/> />
<MonitorLayout </div>
kpis={
<KpiRow> {/* Single Row Layout */}
<KpiCard <div className="relative p-6 flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
label={t('home.profiles')} {/* Left: Logo + Title */}
<HeroSection version={overview?.version} />
{/* Right: Inline Stats */}
<div className="flex flex-wrap items-center gap-3">
<InlineStat
title={t('home.profiles')}
value={overview?.profiles ?? 0} value={overview?.profiles ?? 0}
icon={<KeyIcon className="size-4" />} icon={Key}
variant="accent"
onClick={() => navigate('/providers')} onClick={() => navigate('/providers')}
/> />
<KpiCard <InlineStat
label={t('home.cliproxy')} title={t('home.cliproxy')}
value={overview?.cliproxy ?? 0} value={overview?.cliproxy ?? 0}
icon={<Zap className="size-4" />} icon={Zap}
variant="accent"
onClick={() => navigate('/cliproxy')} onClick={() => navigate('/cliproxy')}
/> />
<KpiCard <InlineStat
label={t('home.accounts')} title={t('home.accounts')}
value={overview?.accounts ?? 0} value={overview?.accounts ?? 0}
icon={<UsersIcon className="size-4" />} icon={Users}
variant="default"
onClick={() => navigate('/accounts')} onClick={() => navigate('/accounts')}
/> />
<KpiCard <InlineStat
label={t('home.health')} title={t('home.health')}
value={overview?.health ? `${overview.health.passed}/${overview.health.total}` : ''} value={overview?.health ? `${overview.health.passed}/${overview.health.total}` : '-'}
tone={healthTone} icon={Activity}
icon={<ActivityIcon className="size-4" />} variant={healthVariant}
onClick={() => navigate('/health')} onClick={() => navigate('/health')}
/> />
</KpiRow> </div>
} </div>
> </div>
{shared?.symlinkStatus && !shared.symlinkStatus.valid && (
<Alert variant="warning">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{t('home.configurationRequired')}</AlertTitle>
<AlertDescription>{shared.symlinkStatus.message}</AlertDescription>
</Alert>
)}
<MonitorGrid> {/* Configuration Warning */}
{/* Live account monitor — primary viz */} {shared?.symlinkStatus && !shared.symlinkStatus.valid && (
<MonitorCard <Alert variant="warning">
span={12} <AlertTriangle className="h-4 w-4" />
title={ <AlertTitle>{t('home.configurationRequired')}</AlertTitle>
<span className="flex items-center gap-2"> <AlertDescription>{shared.symlinkStatus.message}</AlertDescription>
<ShieldCheck className="size-4" /> </Alert>
Live account monitor )}
</span>
}
>
<AuthMonitor />
</MonitorCard>
{/* Logs callout */} {/* Auth Monitor */}
<MonitorCard span={12}> <AuthMonitor />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-3"> <div className="rounded-xl border bg-card/70 p-5">
<div className="rounded-xl bg-muted p-2.5"> <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<ScrollText className="h-5 w-5 text-muted-foreground" /> <div className="flex items-start gap-3">
</div> <div className="rounded-xl bg-muted p-2.5">
<div className="space-y-1"> <ScrollText className="h-5 w-5 text-muted-foreground" />
<h2 className="text-base font-semibold">{t('homePageV2.logsMoved')}</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Use the unified logs page for source-level filtering, structured entry
inspection, and retention policy edits without crowding the home dashboard.
</p>
</div>
</div>
<Button variant="outline" className="gap-2" onClick={() => navigate('/logs')}>
Open logs
<ArrowRight className="h-4 w-4" />
</Button>
</div> </div>
</MonitorCard> <div className="space-y-1">
<h2 className="text-lg font-semibold">{t('homePageV2.logsMoved')}</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Use the unified logs page for source-level filtering, structured entry inspection,
and retention policy edits without crowding the home dashboard.
</p>
</div>
</div>
<Button variant="outline" className="gap-2" onClick={() => navigate('/logs')}>
{/* TODO i18n: missing key for "Open logs" */}
Open logs
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
{/* Recent errors */} <ErrorLogsMonitor />
<MonitorCard </div>
span={12}
title={
<span className="flex items-center gap-2">
<AlertTriangle className="size-4" />
Recent errors
</span>
}
>
<ErrorLogsMonitor />
</MonitorCard>
</MonitorGrid>
</MonitorLayout>
</PageShell>
);
}
function HomeLoadingSkeleton() {
return (
<PageShell>
<PageHeader title={<Skeleton className="h-6 w-40" />} />
<MonitorLayout
kpis={
<KpiRow>
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-20 rounded-xl" />
))}
</KpiRow>
}
>
<MonitorGrid>
<MonitorCard span={12}>
<Skeleton className="h-48 w-full rounded" />
</MonitorCard>
</MonitorGrid>
</MonitorLayout>
</PageShell>
); );
} }