mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 22:21:20 +00:00
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:
@@ -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
@@ -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 |
@@ -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'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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user