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
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
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`.
---
## 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)
└─ <archetype body>
┌────────────────────────────────────────────────────────────────────┐
│ [logo] Title [version] ┃ [Stat] [Stat] [Stat] [Stat] │
└────────────────────────────────────────────────────────────────────┘
```
```tsx
<PageShell>
<PageHeader title="Cliproxy" status={} actions={} />
<ConfigLayout /> {/* OR <MonitorLayout> */}
</PageShell>
One row packs logo + title + version + ≤4 inline stats. Optional subtle dotted-pattern background. Stats are clickable when they double as navigation entry points.
**Use it when:**
- The page is a dashboard / monitor with a clear product identity
- ≤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:
- `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.
Page identity (brand + page-level CTA + status) lives **inside the left rail**. Zero top chrome — the body archetype gets the full vertical viewport.
**Use it when:**
- 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
// Multi-entity (cliproxy, accounts, providers)
<ConfigLayout
left={<ListPane items={} selectedId={id} onSelect={} />}
left={<ListPane />} // multi-entity
// OR
left={<SectionRail />} // single-entity
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
**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`.
- **<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).
### 2b. Monitor — KPI row + 12-col grid
### 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).
**Canonical reference:** `pages/health.tsx`
```
┌────────────────────────────────────────────────
│ 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}> ... │
└────────────────────────────────────────────────┘
┌────────────────────────────────────────┐
│ KpiRow (≤4 hero numbers) │
├────────────────────────────────────────┤
│ MonitorGrid (12-col): │
│ <MonitorCard span={…}/>
└────────────────────────────────────────┘
```
```tsx
<MonitorLayout
kpis={
<KpiRow>
<KpiCard label="Active accounts" value="87" hint="▲ 3" tone="positive" />
</KpiRow>
}
>
<MonitorLayout kpis={<KpiRow></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>
<MonitorCard span={6} variant="terminal" title=></MonitorCard>
</MonitorGrid>
</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.
- **Spans clamp at 6 cols on tablet**, single column on mobile.
---
## 3. Composing a new page
```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
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.
These remain bespoke and are out of scope:
- `/login` — minimal centered shell
- Setup wizard — modal overlay
- Dialogs — Radix `Dialog`
---
## 5. Composing a new page
## 5. Decisions
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.
See [`design-decisions.md`](./design-decisions.md) for the resolved open questions and the v1.1 revision rationale.
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>
<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.
Three identity-strip patterns <strong>HeroBar</strong> (one-row dense, see <code>/</code>{' '}
home page), <strong>rail-anchored</strong> (no top chrome, see <code>/cliproxy</code>), and{' '}
<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>
</header>
);
+219 -225
View File
@@ -11,8 +11,6 @@ import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Skeleton } from '@/components/ui/skeleton';
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 { AddAccountDialog } from '@/components/account/add-account-dialog';
import { AccountSafetyWarningCard } from '@/components/account/account-safety-warning-card';
@@ -354,238 +352,234 @@ export function CliproxyPage() {
setSelectedProvider(null);
};
const sidebar = (
<div className="flex h-full flex-col bg-muted/30">
{/* Header inside the rail: Quick Setup CTA */}
<div className="border-b bg-background p-3">
<Button
variant="default"
size="sm"
className="w-full gap-2"
onClick={() => setWizardOpen(true)}
>
<Sparkles className="w-4 h-4" />
{t('cliproxyPage.quickSetup')}
</Button>
</div>
{/* Providers List */}
<ScrollArea className="flex-1">
<div className="p-2">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2">
{t('cliproxyPage.providers')}
return (
<div className="flex h-full min-h-0 overflow-hidden">
{/* Left Sidebar */}
<div className="w-80 border-r flex flex-col bg-muted/30">
{/* Header */}
<div className="p-4 border-b bg-background">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<Zap className="w-5 h-5 text-primary" />
<h1 className="font-semibold">{updateCheck?.backendLabel ?? 'CLIProxy'}</h1>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleRefresh}
disabled={isFetching}
>
<RefreshCw className={cn('w-4 h-4', isFetching && 'animate-spin')} />
</Button>
</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 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>
)}
<p className="text-xs text-muted-foreground mb-3">
{t('cliproxyPage.accountManagement')}
</p>
{/* 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}
/>
<Button
variant="default"
size="sm"
className="w-full gap-2"
onClick={() => setWizardOpen(true)}
>
<Sparkles className="w-4 h-4" />
{t('cliproxyPage.quickSetup')}
</Button>
</div>
{/* Providers List */}
<ScrollArea className="flex-1">
<div className="p-2">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2">
{t('cliproxyPage.providers')}
</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 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>
</ScrollArea>
{/* Proxy Status Widget */}
<div className="border-t p-3">
<ProxyStatusWidget />
</div>
{/* Footer Stats */}
<div className="border-t bg-background p-3 text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span>{t('cliproxyPage.providerCount', { count: providers.length })}</span>
<span className="flex items-center gap-1">
<Check className="w-3 h-3 text-green-600" />
{t('cliproxyPage.connectedCount', {
count: providers.filter((p) => p.authenticated).length,
})}
</span>
{/* Footer Stats */}
<div className="p-3 border-t bg-background text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span>{t('cliproxyPage.providerCount', { count: providers.length })}</span>
<span className="flex items-center gap-1">
<Check className="w-3 h-3 text-green-600" />
{t('cliproxyPage.connectedCount', {
count: providers.filter((p) => p.authenticated).length,
})}
</span>
</div>
</div>
</div>
</div>
);
const detail = (
<div className="flex h-full min-w-0 flex-col overflow-hidden bg-background">
{selectedVariantData && parentAuthForVariant ? (
<>
<ProviderEditor
provider={selectedVariantData.name}
displayName={t('cliproxyPage.variantDisplay', {
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({
{/* Right Panel */}
<div className="flex-1 flex min-w-0 flex-col overflow-hidden bg-background">
{selectedVariantData && parentAuthForVariant ? (
<>
<ProviderEditor
provider={selectedVariantData.name}
displayName={t('cliproxyPage.variantDisplay', {
name: selectedVariantData.name,
provider: selectedVariantData.provider,
displayName: parentAuthForVariant.displayName,
isFirstAccount: (parentAuthForVariant.accounts?.length || 0) === 0,
})
}
onSetDefault={(accountId) =>
setDefaultMutation.mutate({
provider: selectedVariantData.provider,
accountId,
})
}
onRemoveAccount={(accountId) =>
removeMutation.mutate({
provider: selectedVariantData.provider,
accountId,
})
}
onPauseToggle={(accountId, paused) =>
handlePauseToggle(selectedVariantData.provider, accountId, paused)
}
onSoloMode={(accountId) => handleSoloMode(selectedVariantData.provider, accountId)}
onBulkPause={(accountIds) => handleBulkPause(selectedVariantData.provider, accountIds)}
onBulkResume={(accountIds) =>
handleBulkResume(selectedVariantData.provider, accountIds)
}
isRemovingAccount={removeMutation.isPending}
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
isSoloingAccount={soloMutation.isPending}
isBulkPausing={bulkPauseMutation.isPending}
isBulkResuming={bulkResumeMutation.isPending}
/>
</>
) : selectedStatus ? (
<>
<ProviderEditor
provider={selectedStatus.provider}
displayName={selectedStatus.displayName}
authStatus={selectedStatus}
catalog={catalogs[selectedStatus.provider]}
routing={routingHints[selectedStatus.provider]}
isRemoteMode={isRemoteMode}
topNotice={
showAccountSafetyWarning ? (
<AccountSafetyWarningCard compact showProxySettingsLink />
) : undefined
}
onAddAccount={() =>
setAddAccountProvider({
provider: selectedStatus.provider,
displayName: selectedStatus.displayName,
isFirstAccount: (selectedStatus.accounts?.length || 0) === 0,
})
}
onSetDefault={(accountId) =>
setDefaultMutation.mutate({
provider: selectedStatus.provider,
accountId,
})
}
onRemoveAccount={(accountId) =>
removeMutation.mutate({
provider: selectedStatus.provider,
accountId,
})
}
onPauseToggle={(accountId, paused) =>
handlePauseToggle(selectedStatus.provider, accountId, paused)
}
onSoloMode={(accountId) => handleSoloMode(selectedStatus.provider, accountId)}
onBulkPause={(accountIds) => handleBulkPause(selectedStatus.provider, accountIds)}
onBulkResume={(accountIds) => handleBulkResume(selectedStatus.provider, accountIds)}
isRemovingAccount={removeMutation.isPending}
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
isSoloingAccount={soloMutation.isPending}
isBulkPausing={bulkPauseMutation.isPending}
isBulkResuming={bulkResumeMutation.isPending}
/>
</>
) : (
<EmptyProviderState onSetup={() => setWizardOpen(true)} />
)}
</div>
);
return (
<PageShell>
<PageHeader
title={
<span className="flex items-center gap-2">
<Zap className="w-5 h-5 text-primary" />
{updateCheck?.backendLabel ?? 'CLIProxy'}
</span>
}
description={t('cliproxyPage.accountManagement')}
actions={
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleRefresh}
disabled={isFetching}
aria-label="Refresh"
>
<RefreshCw className={cn('w-4 h-4', isFetching && 'animate-spin')} />
</Button>
}
/>
<ConfigLayout left={sidebar} form={detail} />
})}
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,
displayName: parentAuthForVariant.displayName,
isFirstAccount: (parentAuthForVariant.accounts?.length || 0) === 0,
})
}
onSetDefault={(accountId) =>
setDefaultMutation.mutate({
provider: selectedVariantData.provider,
accountId,
})
}
onRemoveAccount={(accountId) =>
removeMutation.mutate({
provider: selectedVariantData.provider,
accountId,
})
}
onPauseToggle={(accountId, paused) =>
handlePauseToggle(selectedVariantData.provider, accountId, paused)
}
onSoloMode={(accountId) => handleSoloMode(selectedVariantData.provider, accountId)}
onBulkPause={(accountIds) =>
handleBulkPause(selectedVariantData.provider, accountIds)
}
onBulkResume={(accountIds) =>
handleBulkResume(selectedVariantData.provider, accountIds)
}
isRemovingAccount={removeMutation.isPending}
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
isSoloingAccount={soloMutation.isPending}
isBulkPausing={bulkPauseMutation.isPending}
isBulkResuming={bulkResumeMutation.isPending}
/>
</>
) : selectedStatus ? (
<>
<ProviderEditor
provider={selectedStatus.provider}
displayName={selectedStatus.displayName}
authStatus={selectedStatus}
catalog={catalogs[selectedStatus.provider]}
routing={routingHints[selectedStatus.provider]}
isRemoteMode={isRemoteMode}
topNotice={
showAccountSafetyWarning ? (
<AccountSafetyWarningCard compact showProxySettingsLink />
) : undefined
}
onAddAccount={() =>
setAddAccountProvider({
provider: selectedStatus.provider,
displayName: selectedStatus.displayName,
isFirstAccount: (selectedStatus.accounts?.length || 0) === 0,
})
}
onSetDefault={(accountId) =>
setDefaultMutation.mutate({
provider: selectedStatus.provider,
accountId,
})
}
onRemoveAccount={(accountId) =>
removeMutation.mutate({
provider: selectedStatus.provider,
accountId,
})
}
onPauseToggle={(accountId, paused) =>
handlePauseToggle(selectedStatus.provider, accountId, paused)
}
onSoloMode={(accountId) => handleSoloMode(selectedStatus.provider, accountId)}
onBulkPause={(accountIds) => handleBulkPause(selectedStatus.provider, accountIds)}
onBulkResume={(accountIds) => handleBulkResume(selectedStatus.provider, accountIds)}
isRemovingAccount={removeMutation.isPending}
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
isSoloingAccount={soloMutation.isPending}
isBulkPausing={bulkPauseMutation.isPending}
isBulkResuming={bulkResumeMutation.isPending}
/>
</>
) : (
<EmptyProviderState onSetup={() => setWizardOpen(true)} />
)}
</div>
{/* Dialogs */}
<QuickSetupWizard open={wizardOpen} onClose={() => setWizardOpen(false)} />
@@ -601,6 +595,6 @@ export function CliproxyPage() {
}
isFirstAccount={addAccountProvider?.isFirstAccount || false}
/>
</PageShell>
</div>
);
}
+168 -137
View File
@@ -1,38 +1,68 @@
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
ActivityIcon,
AlertTriangle,
ArrowRight,
KeyIcon,
ScrollText,
ShieldCheck,
UsersIcon,
Zap,
} from 'lucide-react';
import { HeroSection } from '@/components/layout/hero-section';
import { AuthMonitor } from '@/components/monitoring/auth-monitor';
import { ErrorLogsMonitor } from '@/components/error-logs-monitor';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Key, Zap, Users, Activity, AlertTriangle, ArrowRight, ScrollText } from 'lucide-react';
import { useOverview } from '@/hooks/use-overview';
import { useSharedSummary } from '@/hooks/use-shared';
import { PageShell, PageHeader } from '@/components/page-shell';
import {
MonitorLayout,
KpiRow,
KpiCard,
MonitorGrid,
MonitorCard,
} from '@/components/monitor-layout';
import { cn } from '@/lib/utils';
import type { LucideIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const HEALTH_TONE = {
ok: 'positive',
const HEALTH_VARIANTS = {
ok: 'success',
warning: 'warning',
error: 'negative',
error: 'error',
} 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() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -40,138 +70,139 @@ export function HomePage() {
const { data: shared, isLoading: isSharedLoading } = useSharedSummary();
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
? HEALTH_TONE[overview.health.status as keyof typeof HEALTH_TONE]
: 'default';
const healthVariant = overview?.health
? HEALTH_VARIANTS[overview.health.status as keyof typeof HEALTH_VARIANTS]
: undefined;
return (
<PageShell>
<PageHeader
title={t('heroSection.title')}
description={t('heroSection.subtitle')}
status={
overview?.version && (
<Badge variant="outline" className="font-mono text-xs">
v{overview.version}
</Badge>
)
}
/>
<MonitorLayout
kpis={
<KpiRow>
<KpiCard
label={t('home.profiles')}
<div className="p-6 space-y-6">
{/* Hero Row: Logo/Title + Inline Stats */}
<div className="relative overflow-hidden rounded-xl border bg-gradient-to-br from-background via-background to-muted/30">
{/* Subtle background pattern */}
<div className="absolute inset-0 opacity-[0.03] pointer-events-none">
<div
className="absolute inset-0"
style={{
backgroundImage: `radial-gradient(circle at 1px 1px, currentColor 1px, transparent 0)`,
backgroundSize: '24px 24px',
}}
/>
</div>
{/* Single Row Layout */}
<div className="relative p-6 flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
{/* 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}
icon={<KeyIcon className="size-4" />}
icon={Key}
variant="accent"
onClick={() => navigate('/providers')}
/>
<KpiCard
label={t('home.cliproxy')}
<InlineStat
title={t('home.cliproxy')}
value={overview?.cliproxy ?? 0}
icon={<Zap className="size-4" />}
icon={Zap}
variant="accent"
onClick={() => navigate('/cliproxy')}
/>
<KpiCard
label={t('home.accounts')}
<InlineStat
title={t('home.accounts')}
value={overview?.accounts ?? 0}
icon={<UsersIcon className="size-4" />}
icon={Users}
variant="default"
onClick={() => navigate('/accounts')}
/>
<KpiCard
label={t('home.health')}
value={overview?.health ? `${overview.health.passed}/${overview.health.total}` : ''}
tone={healthTone}
icon={<ActivityIcon className="size-4" />}
<InlineStat
title={t('home.health')}
value={overview?.health ? `${overview.health.passed}/${overview.health.total}` : '-'}
icon={Activity}
variant={healthVariant}
onClick={() => navigate('/health')}
/>
</KpiRow>
}
>
{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>
)}
</div>
</div>
</div>
<MonitorGrid>
{/* Live account monitor — primary viz */}
<MonitorCard
span={12}
title={
<span className="flex items-center gap-2">
<ShieldCheck className="size-4" />
Live account monitor
</span>
}
>
<AuthMonitor />
</MonitorCard>
{/* Configuration Warning */}
{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>
)}
{/* Logs callout */}
<MonitorCard span={12}>
<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 bg-muted p-2.5">
<ScrollText className="h-5 w-5 text-muted-foreground" />
</div>
<div className="space-y-1">
<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>
{/* Auth Monitor */}
<AuthMonitor />
<div className="rounded-xl border bg-card/70 p-5">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-start gap-3">
<div className="rounded-xl bg-muted p-2.5">
<ScrollText className="h-5 w-5 text-muted-foreground" />
</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 */}
<MonitorCard
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>
<ErrorLogsMonitor />
</div>
);
}