Portal: full mock-driven surfaces, demonolithed components, backend-ready mocks (#6686)

This commit is contained in:
Reece Browne
2026-06-16 12:20:35 +01:00
committed by GitHub
parent 6716398ccb
commit 96accea984
146 changed files with 12221 additions and 286 deletions
+76
View File
@@ -0,0 +1,76 @@
# Portal data layer — mock backend & handover
The portal is **mock-driven**. Every screen fetches real HTTP requests through a
thin typed API layer; in dev and Storybook those requests are intercepted by
[MSW](https://mswjs.io/) and answered with fixture data. Pointing the portal at a
**real backend** is a matter of *not registering MSW* — no component or API-layer
code changes.
## The three layers
```
view (useAsync) ──► api/<surface>.ts ──► httpJson(fetch) ──► MSW handler (dev) ──► fixture builder
(the contract) └► real backend (prod) ─┘
```
1. **`api/<surface>.ts`** — thin, typed `httpJson` wrappers. **This is the backend
contract.** Each function documents its endpoint (method, path, query params)
and its response type. Nothing else in the app issues fetches.
2. **`mocks/handlers/<surface>.ts`** — MSW handlers that answer those endpoints
with `mocks/<surface>.ts` fixtures. Registered in `mocks/handlers/index.ts`.
3. **`mocks/<surface>.ts`** — fixture builders **and the canonical TS types**
(re-exported through `api/<surface>.ts`, so consumers import types from `api/`).
`api/http.ts` is the single `fetch` wrapper (sets headers, throws `HttpError` on
non-2xx). Views consume via `useAsync()` + `useSectionFlags()` (`hooks/useAsync.ts`).
## Conventions (consistent across every surface)
- **Tier**: tier-specific endpoints take `?tier=free|pro|enterprise`. The view
passes `useTier().tier` and refetches on change (`useAsync(fn, [tier])`).
- **Latency**: handlers `await delay(120)` to exercise loading/skeleton states.
- **Read-only today**: surfaces are GET-driven. The few writes (mark-all-read,
op run) exist; composer "Deploy", connect-source wizard, and create-key are
**demo shells** (local state, no submit endpoint yet) — wire these to real
POSTs during backend integration.
## Swapping in a real backend
1. Stop registering MSW (`mocks/browser.ts` / the dev bootstrap) — or gate it on
an env flag (it's already dev-only via `import.meta.env.DEV`).
2. Make `httpJson` hit your API origin (add a `baseURL`/proxy in `api/http.ts`).
3. Match the response shapes in `api/<surface>.ts` (the exported types are the spec).
4. Delete `mocks/` once parity is confirmed. Optionally relocate the types from
`mocks/<surface>.ts` into `api/` (or a `types/` module) so they no longer live
beside fixtures — purely cosmetic; the `api/` re-exports already shield consumers.
## Endpoint catalogue
| Surface | Method & path | Query | API fn | Response type |
|---|---|---|---|---|
| Home | `GET /v1/home/kpis` | `tier` | `fetchHomeKpis` | `KpiEntry[]` |
| Home | `GET /v1/analytics/usage` | — | `fetchUsageSeries` | `UsageSeriesResponse` |
| Home | `GET /v1/activity` | — | `fetchRecentActivity` | `ActivityEvent[]` |
| Home | `GET /v1/regions/health` | — | `fetchRegionHealth` | `RegionHealth[]` |
| Home | `GET /v1/onboarding` | — | `fetchOnboarding` | `OnboardingStep[]` |
| Documents | `GET /v1/endpoints` | `vertical?` | `fetchVerticals` | `Vertical[]` |
| Pipelines | `GET /v1/pipelines` | `tier` | `fetchPipelines` | `PipelinesResponse` |
| Sources | `GET /v1/sources` | `tier` | `fetchSources` | `SourcesResponse` |
| Infrastructure | `GET /v1/infrastructure/deployments` | `tier` | `fetchDeployments` | `DeploymentsResponse` |
| Infrastructure | `GET /v1/infrastructure/api-keys` | `tier` | `fetchApiKeys` | `ApiKey[]` |
| Infrastructure | `GET /v1/infrastructure/security` | `tier` | `fetchSecurity` | `SecurityConfig` |
| Infrastructure | `GET /v1/infrastructure/storage` | `tier` | `fetchStorage` | `StorageConfig` |
| Infrastructure | `GET /v1/infrastructure/audit-log` | `tier` | `fetchAuditLog` | `AuditLogResponse` |
| Usage & Billing | `GET /v1/billing/usage` | — | `fetchBillingUsage` | `UsageSeriesResponse` |
| Usage & Billing | `GET /v1/billing/summary` | `tier` | `fetchBillingSummary` | `BillingSummary` |
| Usage & Billing | `GET /v1/billing/plans` | — | `fetchPlanOptions` | `PlanOption[]` |
| Usage & Billing | `GET /v1/billing/history` | `tier` | `fetchBillingHistory` | `BillingHistoryRow[]` |
| Developer Docs | `GET /v1/docs/nav` | — | `fetchDocsNav` | `DocsNavSection[]` |
| Settings | `GET /v1/settings` | `tier` | `fetchSettings` | `UserSettings` |
| Notifications | `GET /v1/notifications` · `POST /v1/notifications/mark-all-read` | — | — | `Notification[]` |
| Ops | `GET /v1/ops/featured` · `POST /v1/ops/:opId/run` | — | — | — |
| Assistant | `GET /v1/assistant/suggestions` · `POST /v1/assistant/messages` | — | — | — |
| Search | `GET /v1/search/quick-actions` | — | — | — |
> Catalogue generated from `mocks/handlers/*.ts`. The `api/<surface>.ts` JSDoc on
> each function is the authoritative per-endpoint reference.
+62
View File
@@ -0,0 +1,62 @@
# Portal UI conventions — SUI vs Mantine
The portal has two component sources. The rule:
> **Simple, presentational, brand-defining UI → our SUI design system
> (`@shared/components`). Complex, stateful, or accessibility-hard widgets →
> Mantine.** Don't reinvent what Mantine already does well; do own the look of
> the simple, high-frequency pieces.
Both are theme-bound: `MantineProvider` in `App.tsx` is wired to the portal's
`ThemeProvider` (`mantineTheme.ts` maps the brand palette), so Mantine widgets
follow the same light/dark switch and brand colours as SUI. **The provider is
intentional** — it exists precisely so we can drop Mantine widgets in where they
earn their keep.
## Use SUI (`@shared/components`) — our own style
Layout and presentational primitives we want full brand control over and that
are cheap to own:
`Button` · `Card` · `StatusBadge` / `MethodBadge` · `Chip` · `MetricCard` ·
`MetricStrip` · `StatTile` · `ProgressBar` · `Avatar` · `Banner` · `Skeleton` ·
`Spinner` · `EmptyState` · `NavItem` · `PanelHeader` · `SectionDivider` ·
`Stack` / `Inline` · `Table` (static/presentational) · `CodeBlock` ·
`FormField` (label/help/error layout) · simple `Tabs`.
## Use Mantine — don't reinvent
Anything that needs portals, focus traps, ARIA keyboard patterns, or is just a
solved hard problem:
- **Overlays**: `Modal`, `Drawer`, `Popover` (focus trap, scroll lock, escape, focus restore)
- **Menus**: `Menu` (roving arrow-key navigation)
- **Selects**: `Select` / `MultiSelect` / `Combobox` / `Autocomplete` (keyboard + filtering)
- **Dates**: `@mantine/dates` `DatePicker` / `DatePickerInput` (e.g. billing period range)
- **Files**: `@mantine/dropzone` `Dropzone` (connect-source upload, op-runner sample drop)
- **Progress UX**: `Stepper` (multi-step wizards), `Notifications`, `Tooltip`
- Hooks: prefer `@mantine/hooks` (`useDisclosure`, `useClickOutside`, `useHotkeys`, …) over hand-rolling.
## Why
Mantine is mature and battle-tested for accessibility. A review of the
hand-rolled SUI overlays found real gaps — `Dropdown` has no arrow-key
navigation, `Modal`/`Drawer` mishandle focus when there are no focusable
children, `Toast` uses `role="alert"` for every tone — exactly the things
Mantine gets right. Owning those is wasted effort and an a11y liability.
## Known migrations (hand-rolled today → should be Mantine)
These shipped as SUI primitives during the initial build and should move to
Mantine equivalents (fixes the a11y findings above):
| Today (SUI) | → Mantine |
|---|---|
| `Dropdown` (menus: tier switcher, app switcher, notifications) | `Menu` |
| `Modal` (composer, wizards, settings, create-key) | `Modal` |
| `Drawer` (pipeline detail) | `Drawer` |
| `Toast` | `notifications` |
| _new need:_ billing date range | `@mantine/dates` |
| _new need:_ file upload | `@mantine/dropzone` |
Keep `Tabs` SUI for the simple in-page switchers; only reach for more if a true
tabpanel/roving-focus contract is needed.
> Migrating overlays touches visible chrome and behaviour, so do it deliberately
> (with eyes on the result), not as a blind sweep.
+8
View File
@@ -9,6 +9,7 @@ import { AppShell } from "@portal/components/AppShell";
import { AssistantButton } from "@portal/components/AssistantButton";
import { AssistantPanel } from "@portal/components/AssistantPanel";
import { SearchModal } from "@portal/components/SearchModal";
import { SettingsModal } from "@portal/components/SettingsModal";
import { ViewRouter } from "@portal/ViewRouter";
/**
@@ -52,6 +53,12 @@ function GlobalShortcuts() {
return null;
}
/** Bridges the Settings modal's open/close props to UIContext state. */
function SettingsHost() {
const { settingsOpen, closeSettings } = useUI();
return <SettingsModal open={settingsOpen} onClose={closeSettings} />;
}
export function App() {
return (
<ThemeProvider>
@@ -66,6 +73,7 @@ export function App() {
<AssistantButton />
<AssistantPanel />
<SearchModal />
<SettingsHost />
</UIProvider>
</BrowserRouter>
</TierProvider>
+12 -53
View File
@@ -1,65 +1,24 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { Home } from "@portal/views/Home";
import { Documents } from "@portal/views/Documents";
import { Placeholder } from "@portal/views/Placeholder";
import { VIEW_PATHS, type ViewId } from "@portal/contexts/ViewContext";
const PLACEHOLDER_PHASES: Partial<Record<ViewId, string>> = {
editor: "Phase 8 — Editor",
sources: "Phase 5 — Sources & Agents",
pipelines: "Phase 4 — Pipelines",
infrastructure: "Phase 7 — Infrastructure",
usage: "Phase 8 — Usage & Billing",
docs: "Phase 8 — Developer Docs",
settings: "Settings — modal overlay",
};
import { Pipelines } from "@portal/views/Pipelines";
import { Sources } from "@portal/views/Sources";
import { Infrastructure } from "@portal/views/Infrastructure";
import { Usage } from "@portal/views/Usage";
import { DeveloperDocs } from "@portal/views/DeveloperDocs";
import { VIEW_PATHS } from "@portal/contexts/ViewContext";
export function ViewRouter() {
return (
<Routes>
<Route path={VIEW_PATHS.home} element={<Home />} />
<Route
path={VIEW_PATHS.pipelines}
element={
<Placeholder view="pipelines" phase={PLACEHOLDER_PHASES.pipelines} />
}
/>
<Route
path={VIEW_PATHS.editor}
element={
<Placeholder view="editor" phase={PLACEHOLDER_PHASES.editor} />
}
/>
<Route
path={VIEW_PATHS.sources}
element={
<Placeholder view="sources" phase={PLACEHOLDER_PHASES.sources} />
}
/>
<Route path={VIEW_PATHS.pipelines} element={<Pipelines />} />
<Route path={VIEW_PATHS.sources} element={<Sources />} />
<Route path={VIEW_PATHS.documents} element={<Documents />} />
<Route
path={VIEW_PATHS.infrastructure}
element={
<Placeholder
view="infrastructure"
phase={PLACEHOLDER_PHASES.infrastructure}
/>
}
/>
<Route
path={VIEW_PATHS.usage}
element={<Placeholder view="usage" phase={PLACEHOLDER_PHASES.usage} />}
/>
<Route
path={VIEW_PATHS.docs}
element={<Placeholder view="docs" phase={PLACEHOLDER_PHASES.docs} />}
/>
<Route
path={VIEW_PATHS.settings}
element={
<Placeholder view="settings" phase={PLACEHOLDER_PHASES.settings} />
}
/>
<Route path={VIEW_PATHS.infrastructure} element={<Infrastructure />} />
<Route path={VIEW_PATHS.usage} element={<Usage />} />
<Route path={VIEW_PATHS.docs} element={<DeveloperDocs />} />
{/* Settings is a modal overlay, not a route (see AppShell + UIContext). */}
{/* Unknown paths land on Home. */}
<Route path="*" element={<Navigate to={VIEW_PATHS.home} replace />} />
</Routes>
+27
View File
@@ -0,0 +1,27 @@
import { httpJson } from "@portal/api/http";
import type { Tier } from "@portal/contexts/TierContext";
import type { DocsContent, DocsNavSection } from "@portal/mocks/docs";
export type {
AgentSkill,
ApiErrorRow,
CodeSample,
DocsContent,
DocsNavItem,
DocsNavSection,
EmbedComponent,
Playbook,
RateLimit,
Sdk,
SdkStatus,
} from "@portal/mocks/docs";
/** GET /v1/docs/nav — the docs nav tree. */
export async function fetchDocsNav(): Promise<DocsNavSection[]> {
return httpJson<DocsNavSection[]>("/v1/docs/nav");
}
/** GET /v1/docs/content — the tier-scaled reference content. */
export async function fetchDocsContent(tier: Tier): Promise<DocsContent> {
return httpJson<DocsContent>(`/v1/docs/content?tier=${tier}`);
}
+70
View File
@@ -0,0 +1,70 @@
import { httpJson } from "@portal/api/http";
import type { Tier } from "@portal/contexts/TierContext";
import type {
ApiKey,
AuditLogResponse,
DeploymentRegion,
RecentDeployment,
SecurityConfig,
StorageConfig,
} from "@portal/mocks/infrastructure";
export type {
AccessPolicy,
ApiKey,
ApiKeyPermission,
ApiKeyStatus,
AuditCategory,
AuditEvent,
AuditLogResponse,
AuditStatus,
AuditSummary,
CertStatus,
ComplianceCert,
DataResidency,
DeploymentRegion,
DeploymentStatus,
IpAllowEntry,
RecentDeployment,
RegionStatus,
RetentionWindow,
SecurityConfig,
StorageConfig,
StorageProvider,
} from "@portal/mocks/infrastructure";
export interface DeploymentsResponse {
regions: DeploymentRegion[];
recent: RecentDeployment[];
}
const q = (tier: Tier) => `?tier=${encodeURIComponent(tier)}`;
/** GET /v1/infrastructure/deployments?tier=… */
export async function fetchDeployments(
tier: Tier,
): Promise<DeploymentsResponse> {
return httpJson<DeploymentsResponse>(
`/v1/infrastructure/deployments${q(tier)}`,
);
}
/** GET /v1/infrastructure/api-keys?tier=… */
export async function fetchApiKeys(tier: Tier): Promise<ApiKey[]> {
return httpJson<ApiKey[]>(`/v1/infrastructure/api-keys${q(tier)}`);
}
/** GET /v1/infrastructure/security?tier=… */
export async function fetchSecurity(tier: Tier): Promise<SecurityConfig> {
return httpJson<SecurityConfig>(`/v1/infrastructure/security${q(tier)}`);
}
/** GET /v1/infrastructure/storage?tier=… */
export async function fetchStorage(tier: Tier): Promise<StorageConfig> {
return httpJson<StorageConfig>(`/v1/infrastructure/storage${q(tier)}`);
}
/** GET /v1/infrastructure/audit-log?tier=… */
export async function fetchAuditLog(tier: Tier): Promise<AuditLogResponse> {
return httpJson<AuditLogResponse>(`/v1/infrastructure/audit-log${q(tier)}`);
}
+22
View File
@@ -0,0 +1,22 @@
import { httpJson } from "@portal/api/http";
import type { PipelinesResponse } from "@portal/mocks/pipelines";
import type { Tier } from "@portal/contexts/TierContext";
export type {
EvalsNote,
GoldenSet,
Pipeline,
PipelineMetrics,
PipelinesResponse,
PipelineStatus,
SchemaDrift,
StageKey,
StageSummary,
} from "@portal/mocks/pipelines";
/** GET /v1/pipelines?tier=… — the deployed fleet plus tier-specific extras. */
export async function fetchPipelines(tier: Tier): Promise<PipelinesResponse> {
return httpJson<PipelinesResponse>(
`/v1/pipelines?tier=${encodeURIComponent(tier)}`,
);
}
+16
View File
@@ -0,0 +1,16 @@
import { httpJson } from "@portal/api/http";
import type { SettingsSnapshot } from "@portal/mocks/settings";
import type { Tier } from "@portal/contexts/TierContext";
export type {
NotificationDefault,
RegionOption,
SettingsSnapshot,
} from "@portal/mocks/settings";
/** GET /v1/settings?tier=… — the account + workspace snapshot the modal edits. */
export async function fetchSettings(tier: Tier): Promise<SettingsSnapshot> {
return httpJson<SettingsSnapshot>(
`/v1/settings?tier=${encodeURIComponent(tier)}`,
);
}
+25
View File
@@ -0,0 +1,25 @@
import { httpJson } from "@portal/api/http";
import type { SourcesResponse } from "@portal/mocks/sources";
import type { Tier } from "@portal/contexts/TierContext";
export type {
AgentDetail,
ApiClientDetail,
BasicDetail,
Source,
SourceDetail,
SourceStatus,
SourceType,
SourceTypeMeta,
SourcesKpi,
SourcesResponse,
WebhookDetail,
} from "@portal/mocks/sources";
export { SOURCE_STATUS_TONE, SOURCE_TYPE_META } from "@portal/mocks/sources";
/** GET /v1/sources?tier=… — KPI strip + the sources table for the tier. */
export async function fetchSources(tier: Tier): Promise<SourcesResponse> {
return httpJson<SourcesResponse>(
`/v1/sources?tier=${encodeURIComponent(tier)}`,
);
}
+44
View File
@@ -0,0 +1,44 @@
import { httpJson } from "@portal/api/http";
import type { Tier } from "@portal/contexts/TierContext";
import type {
BillingHistoryRow,
BillingSummary,
PlanOption,
UsageSeriesResponse,
} from "@portal/mocks/usage";
export type {
BillingHistoryRow,
BillingSummary,
InvoiceStatus,
PlanOption,
UsagePoint,
UsageSeriesResponse,
} from "@portal/mocks/usage";
export { OVERAGE_RATE } from "@portal/mocks/usage";
/** GET /v1/billing/usage — 30-day docs-processed series. */
export async function fetchBillingUsage(): Promise<UsageSeriesResponse> {
return httpJson<UsageSeriesResponse>("/v1/billing/usage");
}
/** GET /v1/billing/summary?tier=… — KPI strip + current-plan figures. */
export async function fetchBillingSummary(tier: Tier): Promise<BillingSummary> {
return httpJson<BillingSummary>(
`/v1/billing/summary?tier=${encodeURIComponent(tier)}`,
);
}
/** GET /v1/billing/plans — available plan catalogue. */
export async function fetchPlanOptions(): Promise<PlanOption[]> {
return httpJson<PlanOption[]>("/v1/billing/plans");
}
/** GET /v1/billing/history?tier=… — invoice / line-item history. */
export async function fetchBillingHistory(
tier: Tier,
): Promise<BillingHistoryRow[]> {
return httpJson<BillingHistoryRow[]>(
`/v1/billing/history?tier=${encodeURIComponent(tier)}`,
);
}
@@ -0,0 +1,257 @@
/* The modal body scrolls; keep the tab strip pinned at the top of it. */
.portal-settings__tabs {
position: sticky;
top: -1rem;
z-index: 1;
margin: -1rem -1.125rem 0;
padding: 0.5rem 1.125rem 0;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border-light);
}
.portal-settings__panel {
padding-top: 1rem;
min-height: 18rem;
}
.portal-settings__section {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Footer note pushes the action buttons to the right. */
.portal-settings__footer-note {
margin-right: auto;
align-self: center;
font-size: 0.75rem;
color: var(--color-text-4);
}
/* ── Profile identity row ─────────────────────────────────────────────── */
.portal-settings__identity {
display: flex;
align-items: center;
gap: 0.875rem;
padding: 0.875rem;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-lg);
}
.portal-settings__identity-meta {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
flex: 1 1 auto;
}
.portal-settings__identity-name {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-settings__identity-email {
font-size: 0.8125rem;
color: var(--color-text-4);
}
/* ── Preference groups ────────────────────────────────────────────────── */
.portal-settings__group {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.portal-settings__group + .portal-settings__group {
padding-top: 1rem;
border-top: 1px solid var(--color-border-light);
}
.portal-settings__group-head {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.portal-settings__group-title {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-settings__group-sub {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-4);
}
/* ── Theme picker ─────────────────────────────────────────────────────── */
.portal-settings__theme {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.625rem;
}
.portal-settings__theme-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
text-align: left;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
transition:
border-color var(--motion-fast),
background var(--motion-fast),
box-shadow var(--motion-fast);
}
.portal-settings__theme-card:hover {
border-color: var(--color-border-strong, var(--color-border));
background: var(--color-bg-hover);
}
.portal-settings__theme-card.is-active {
border-color: var(--color-blue);
box-shadow: 0 0 0 1px var(--color-blue);
}
.portal-settings__theme-swatch {
display: inline-flex;
flex-direction: column;
gap: 2px;
width: 2.25rem;
height: 2.25rem;
padding: 4px;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
flex: 0 0 auto;
}
.portal-settings__theme-swatch span {
border-radius: 2px;
}
.portal-settings__theme-swatch span:first-child {
flex: 0 0 35%;
}
.portal-settings__theme-swatch span:last-child {
flex: 1 1 auto;
}
.portal-settings__theme-swatch--light {
background: #ffffff;
}
.portal-settings__theme-swatch--light span:first-child {
background: #cbd5e1;
}
.portal-settings__theme-swatch--light span:last-child {
background: #eef2f7;
}
.portal-settings__theme-swatch--dark {
background: #0f172a;
}
.portal-settings__theme-swatch--dark span:first-child {
background: #475569;
}
.portal-settings__theme-swatch--dark span:last-child {
background: #1e293b;
}
.portal-settings__theme-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.portal-settings__theme-text strong {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-settings__theme-text span {
font-size: 0.75rem;
color: var(--color-text-4);
}
/* ── Notification rows ────────────────────────────────────────────────── */
.portal-settings__notifs {
display: flex;
flex-direction: column;
}
.portal-settings__notif-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 0;
}
.portal-settings__notif-row + .portal-settings__notif-row {
border-top: 1px solid var(--color-border-light);
}
.portal-settings__notif-text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.portal-settings__notif-text strong {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-1);
}
.portal-settings__notif-text span {
font-size: 0.75rem;
color: var(--color-text-4);
}
/* ── Workspace plan card ──────────────────────────────────────────────── */
.portal-settings__plan {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 0.875rem;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-lg);
}
.portal-settings__plan-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.portal-settings__plan-label {
font-size: 0.8125rem;
color: var(--color-text-3);
}
.portal-settings__plan-value {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-1);
}
@media (max-width: 40rem) {
.portal-settings__theme {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,471 @@
import { useEffect, useMemo, useState } from "react";
import {
Avatar,
Button,
FormField,
Input,
Modal,
Select,
Skeleton,
StatusBadge,
Tabs,
ToggleSwitch,
type SelectOption,
type TabItem,
} from "@shared/components";
import { useTier } from "@portal/contexts/TierContext";
import { useTheme, type Theme } from "@portal/contexts/ThemeContext";
import { useAsync } from "@portal/hooks/useAsync";
import { fetchSettings, type SettingsSnapshot } from "@portal/api/settings";
import "@portal/components/SettingsModal.css";
type SettingsTab = "profile" | "preferences" | "workspace";
interface SettingsModalProps {
open: boolean;
onClose: () => void;
}
/** Display copy for each notification category, keyed by the snapshot id. */
const NOTIFICATION_COPY: Record<
string,
{ label: string; description: string }
> = {
"pipeline-failures": {
label: "Pipeline failures",
description: "A run errors out or a step times out.",
},
"pipeline-success": {
label: "Pipeline completions",
description: "Every successful pipeline run finishes.",
},
"usage-alerts": {
label: "Usage & quota alerts",
description: "You approach a plan limit or rate cap.",
},
"weekly-digest": {
label: "Weekly digest",
description: "A Monday summary of volume and health.",
},
"security-alerts": {
label: "Security alerts",
description: "New API keys, sign-ins, or permission changes.",
},
"product-updates": {
label: "Product updates",
description: "New operations, sources, and release notes.",
},
};
const TABS: TabItem<SettingsTab>[] = [
{ key: "profile", label: "Profile" },
{ key: "preferences", label: "Preferences" },
{ key: "workspace", label: "Workspace" },
];
const THEME_OPTIONS: { value: Theme; label: string; hint: string }[] = [
{ value: "light", label: "Light", hint: "Bright surfaces" },
{ value: "dark", label: "Dark", hint: "Dim surfaces" },
];
/**
* Account settings as a portal-wide overlay. Opens onto a tier-aware snapshot
* (profile, notification defaults, workspace + region) which seeds editable
* local form state. Save is a no-op for the demo — it simply closes — but the
* theme control writes straight through to ThemeProvider so the change is real
* and visible immediately.
*/
export function SettingsModal({ open, onClose }: SettingsModalProps) {
const { tier } = useTier();
const { theme, setTheme } = useTheme();
const [tab, setTab] = useState<SettingsTab>("profile");
const { data: snapshot, loading } = useAsync<SettingsSnapshot>(
() => fetchSettings(tier),
[tier],
);
// Editable copies seeded from the snapshot. Re-seed whenever a fresh snapshot
// arrives (tier switch) or the modal is re-opened, so edits never leak across
// sessions or stack on stale values.
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [workspaceName, setWorkspaceName] = useState("");
const [region, setRegion] = useState("");
const [notifications, setNotifications] = useState<Record<string, boolean>>(
{},
);
useEffect(() => {
if (!snapshot) return;
setName(snapshot.profile.name);
setEmail(snapshot.profile.email);
setWorkspaceName(snapshot.workspace.name);
setRegion(snapshot.workspace.region);
setNotifications(
Object.fromEntries(snapshot.notifications.map((n) => [n.id, n.enabled])),
);
}, [snapshot]);
useEffect(() => {
if (open) setTab("profile");
}, [open]);
const regionOptions = useMemo<SelectOption[]>(() => {
if (!snapshot) return [];
return snapshot.regions.map((r) => ({
value: r.value,
label:
r.enterpriseOnly && tier !== "enterprise"
? `${r.label} · Enterprise`
: r.label,
disabled: r.enterpriseOnly && tier !== "enterprise",
}));
}, [snapshot, tier]);
const isLoading = loading && !snapshot;
return (
<Modal
open={open}
onClose={onClose}
width="lg"
title="Settings"
subtitle="Manage your profile, preferences, and workspace."
className="portal-settings"
footer={
<>
<span className="portal-settings__footer-note">
Changes apply to this workspace.
</span>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant="gradient" onClick={onClose}>
Save changes
</Button>
</>
}
>
<div className="portal-settings__tabs">
<Tabs
items={TABS}
activeKey={tab}
onChange={setTab}
variant="underline"
ariaLabel="Settings sections"
/>
</div>
<div className="portal-settings__panel">
{tab === "profile" && (
<ProfilePanel
loading={isLoading}
name={name}
email={email}
role={snapshot?.profile.role}
avatarUrl={snapshot?.profile.avatarUrl ?? undefined}
onName={setName}
onEmail={setEmail}
/>
)}
{tab === "preferences" && (
<PreferencesPanel
loading={isLoading}
notifications={notifications}
order={snapshot?.notifications.map((n) => n.id) ?? []}
onToggle={(id, value) =>
setNotifications((prev) => ({ ...prev, [id]: value }))
}
theme={theme}
onTheme={setTheme}
/>
)}
{tab === "workspace" && (
<WorkspacePanel
loading={isLoading}
workspaceName={workspaceName}
onWorkspaceName={setWorkspaceName}
region={region}
onRegion={setRegion}
regionOptions={regionOptions}
planLabel={snapshot?.workspace.planLabel}
seats={snapshot?.workspace.seats}
/>
)}
</div>
</Modal>
);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Profile */
/* ──────────────────────────────────────────────────────────────────────── */
function ProfilePanel({
loading,
name,
email,
role,
avatarUrl,
onName,
onEmail,
}: {
loading: boolean;
name: string;
email: string;
role?: string;
avatarUrl?: string;
onName: (v: string) => void;
onEmail: (v: string) => void;
}) {
if (loading) {
return (
<div className="portal-settings__section">
<div className="portal-settings__identity">
<Skeleton width="3.5rem" height="3.5rem" shape="circle" />
<div className="portal-settings__identity-meta">
<Skeleton width="9rem" />
<Skeleton width="12rem" height="0.625rem" />
</div>
</div>
<Skeleton height="3rem" />
<Skeleton height="3rem" />
</div>
);
}
return (
<div className="portal-settings__section">
<div className="portal-settings__identity">
<Avatar
src={avatarUrl}
name={name || "Account"}
size="lg"
tone="blue"
/>
<div className="portal-settings__identity-meta">
<div className="portal-settings__identity-name">
{name || "Account"}
{role && (
<StatusBadge tone="info" size="sm" showDot={false}>
{role}
</StatusBadge>
)}
</div>
<span className="portal-settings__identity-email">{email}</span>
</div>
<Button variant="outline" size="sm" disabled>
Change photo
</Button>
</div>
<FormField label="Full name">
<Input
value={name}
onChange={(e) => onName(e.target.value)}
placeholder="Your name"
/>
</FormField>
<FormField
label="Email"
helperText="Used for sign-in and notification delivery."
>
<Input
type="email"
value={email}
onChange={(e) => onEmail(e.target.value)}
placeholder="[email protected]"
/>
</FormField>
</div>
);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Preferences */
/* ──────────────────────────────────────────────────────────────────────── */
function PreferencesPanel({
loading,
notifications,
order,
onToggle,
theme,
onTheme,
}: {
loading: boolean;
notifications: Record<string, boolean>;
order: string[];
onToggle: (id: string, value: boolean) => void;
theme: Theme;
onTheme: (theme: Theme) => void;
}) {
return (
<div className="portal-settings__section">
<div className="portal-settings__group">
<div className="portal-settings__group-head">
<h3 className="portal-settings__group-title">Appearance</h3>
<p className="portal-settings__group-sub">
Choose how the portal looks on this device.
</p>
</div>
<div
className="portal-settings__theme"
role="radiogroup"
aria-label="Theme"
>
{THEME_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={theme === opt.value}
className={
"portal-settings__theme-card" +
(theme === opt.value ? " is-active" : "")
}
onClick={() => onTheme(opt.value)}
>
<span
className={`portal-settings__theme-swatch portal-settings__theme-swatch--${opt.value}`}
aria-hidden
>
<span />
<span />
</span>
<span className="portal-settings__theme-text">
<strong>{opt.label}</strong>
<span>{opt.hint}</span>
</span>
</button>
))}
</div>
</div>
<div className="portal-settings__group">
<div className="portal-settings__group-head">
<h3 className="portal-settings__group-title">Notifications</h3>
<p className="portal-settings__group-sub">
Pick which events reach your inbox.
</p>
</div>
{loading && (
<div className="portal-settings__notifs">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="portal-settings__notif-row">
<div className="portal-settings__notif-text">
<Skeleton width="9rem" />
<Skeleton width="14rem" height="0.625rem" />
</div>
<Skeleton width="2.25rem" height="1.25rem" shape="rect" />
</div>
))}
</div>
)}
{!loading && (
<div className="portal-settings__notifs">
{order.map((id) => {
const copy = NOTIFICATION_COPY[id];
if (!copy) return null;
return (
<div key={id} className="portal-settings__notif-row">
<div className="portal-settings__notif-text">
<strong>{copy.label}</strong>
<span>{copy.description}</span>
</div>
<ToggleSwitch
checked={notifications[id] ?? false}
onChange={(v) => onToggle(id, v)}
/>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Workspace */
/* ──────────────────────────────────────────────────────────────────────── */
function WorkspacePanel({
loading,
workspaceName,
onWorkspaceName,
region,
onRegion,
regionOptions,
planLabel,
seats,
}: {
loading: boolean;
workspaceName: string;
onWorkspaceName: (v: string) => void;
region: string;
onRegion: (v: string) => void;
regionOptions: SelectOption[];
planLabel?: string;
seats?: { used: number; total: number };
}) {
if (loading) {
return (
<div className="portal-settings__section">
<Skeleton height="3rem" />
<Skeleton height="3rem" />
<Skeleton height="4rem" />
</div>
);
}
return (
<div className="portal-settings__section">
<FormField label="Workspace name">
<Input
value={workspaceName}
onChange={(e) => onWorkspaceName(e.target.value)}
placeholder="Workspace name"
/>
</FormField>
<FormField
label="Data residency region"
helperText="Where documents are processed and stored at rest."
>
<Select
value={region}
onChange={(e) => onRegion(e.target.value)}
options={regionOptions}
/>
</FormField>
<div className="portal-settings__plan">
<div className="portal-settings__plan-row">
<span className="portal-settings__plan-label">Plan</span>
<StatusBadge tone="purple" size="sm">
{planLabel ?? "—"}
</StatusBadge>
</div>
{seats && (
<div className="portal-settings__plan-row">
<span className="portal-settings__plan-label">Seats</span>
<span className="portal-settings__plan-value">
{seats.used} of {seats.total} used
</span>
</div>
)}
<Button variant="outline" size="sm" disabled>
Manage billing
</Button>
</div>
</div>
);
}
+29 -5
View File
@@ -20,31 +20,55 @@
border-bottom: 1px solid var(--color-sidebar-divider);
}
.portal-sidebar__wordmark {
/* Editor "Stirling PDF" wordmark logo (theme-switched in Sidebar.tsx) */
.portal-sidebar__brand {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.portal-sidebar__brand-logo {
height: 1.5rem;
width: auto;
display: block;
}
/* TEMP: "portal" suffix styled to read as part of the wordmark */
.portal-sidebar__logo-suffix {
font-family: var(--font-brand);
font-size: 1.125rem;
font-weight: 700;
color: var(--color-logo-text);
letter-spacing: 0.02em;
color: var(--color-text-3);
letter-spacing: 0.01em;
}
.portal-sidebar__workspace {
/* App switcher (down-arrow → Portal / Editor) */
.portal-sidebar__app-switch {
margin-left: auto;
display: flex;
}
.portal-sidebar__app-switch-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border: none;
background: none;
cursor: pointer;
border-radius: var(--radius-sm);
color: var(--color-text-4);
transition:
background var(--motion-fast),
color var(--motion-fast);
}
.portal-sidebar__workspace:hover {
.portal-sidebar__app-switch-btn:hover {
background: var(--color-bg-hover);
color: var(--color-text-2);
}
.portal-sidebar__app-icon {
width: 1rem;
height: 1.0625rem;
display: block;
}
/* Nav body */
.portal-sidebar__nav {
+59 -25
View File
@@ -1,11 +1,16 @@
import { NavItem } from "@shared/components";
import { Dropdown, NavItem } from "@shared/components";
import { useView, type ViewId } from "@portal/contexts/ViewContext";
import { useTier } from "@portal/contexts/TierContext";
import { useTheme } from "@portal/contexts/ThemeContext";
import { useUI } from "@portal/contexts/UIContext";
import { useAsync } from "@portal/hooks/useAsync";
import { fetchHomeKpis, type KpiEntry } from "@portal/api/home";
import wordmarkLight from "@shared/assets/stirling-pdf-logo-light.svg";
import wordmarkDark from "@shared/assets/stirling-pdf-logo-dark.svg";
import markLight from "@shared/assets/stirling-mark-light.svg";
import markDark from "@shared/assets/stirling-mark-dark.svg";
import {
HomeIcon,
EditorIcon,
SourcesIcon,
PipelinesIcon,
DocumentsIcon,
@@ -17,6 +22,12 @@ import {
} from "@portal/components/icons";
import "@portal/components/Sidebar.css";
// TEMP app switcher. The editor is a separate Vite app — no shared shell yet
// (see PORTAL_INTEGRATION_PLAN.md), so switching is a hard navigation: the
// editor's own dev server in dev, the site root in prod. A standalone portal
// deploy will later gate this behind a configured editor URL.
const EDITOR_URL = import.meta.env.DEV ? "http://localhost:5180/" : "/";
interface NavEntry {
id: ViewId;
label: string;
@@ -28,7 +39,6 @@ const GROUP_PRIMARY: NavEntry[] = [
];
const GROUP_OPERATIONAL: NavEntry[] = [
{ id: "editor", label: "Editor", icon: <EditorIcon /> },
{ id: "sources", label: "Sources", icon: <SourcesIcon /> },
{ id: "pipelines", label: "Pipelines", icon: <PipelinesIcon /> },
{ id: "documents", label: "Documents", icon: <DocumentsIcon /> },
@@ -44,22 +54,6 @@ const GROUP_PLATFORM: NavEntry[] = [
{ id: "docs", label: "Developer Docs", icon: <DocsIcon /> },
];
function StirlingMark() {
// The Stirling brand mark — two stacked parallelograms in the brand blues.
return (
<svg
width="22"
height="22"
viewBox="0 0 22 22"
aria-hidden
role="presentation"
>
<path d="M5 4 L20 4 L17 11 L2 11 Z" fill="var(--color-blue)" />
<path d="M5 11 L17 11 L14 18 L2 18 Z" fill="var(--color-blue-border)" />
</svg>
);
}
function UsageFooter() {
const { tier } = useTier();
// Read the same endpoint Home's KPI strip uses so the doc count here can't
@@ -117,6 +111,8 @@ function UsageFooter() {
export function Sidebar() {
const { activeView, setActiveView } = useView();
const { theme } = useTheme();
const { openSettings } = useUI();
function renderGroup(entries: NavEntry[]) {
return entries.map((entry) => (
@@ -134,15 +130,54 @@ export function Sidebar() {
return (
<aside className="portal-sidebar" aria-label="Primary navigation">
<div className="portal-sidebar__logo">
<StirlingMark />
<span className="portal-sidebar__wordmark">Stirling</span>
<span className="portal-sidebar__brand">
<img
className="portal-sidebar__brand-logo"
src={theme === "dark" ? wordmarkDark : wordmarkLight}
alt="Stirling PDF"
/>
<span className="portal-sidebar__logo-suffix">portal</span>
</span>
<Dropdown.Root align="end" className="portal-sidebar__app-switch">
<Dropdown.Trigger>
<button
type="button"
className="portal-sidebar__workspace"
aria-label="Switch workspace"
className="portal-sidebar__app-switch-btn"
aria-label="Switch app"
>
<ChevronDownIcon size={14} />
</button>
</Dropdown.Trigger>
<Dropdown.Menu width="11rem">
<Dropdown.Item
active
leading={
<img
className="portal-sidebar__app-icon"
src={theme === "dark" ? markDark : markLight}
alt=""
/>
}
>
Portal
</Dropdown.Item>
<Dropdown.Item
onSelect={() => {
window.location.href = EDITOR_URL;
}}
leading={
<img
className="portal-sidebar__app-icon"
src={theme === "dark" ? markDark : markLight}
alt=""
/>
}
>
Editor
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Root>
</div>
<nav className="portal-sidebar__nav">
@@ -164,8 +199,7 @@ export function Sidebar() {
id="settings"
label="Settings"
icon={<SettingsIcon />}
isActive={activeView === "settings"}
onClick={(id) => setActiveView(id as ViewId)}
onClick={() => openSettings()}
/>
<UsageFooter />
</div>
@@ -0,0 +1,20 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AuthenticationSection } from "@portal/components/docs/AuthenticationSection";
import "@portal/views/DeveloperDocs.css";
const meta: Meta<typeof AuthenticationSection> = {
title: "Portal/DeveloperDocs/AuthenticationSection",
component: AuthenticationSection,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "46rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof AuthenticationSection>;
export const Default: Story = {};
@@ -0,0 +1,33 @@
import { Chip, CodeBlock } from "@shared/components";
import { DocsSection } from "@portal/components/docs/DocsSection";
export function AuthenticationSection() {
return (
<DocsSection
id="authentication"
eyebrow="GETTING STARTED"
title="Authentication"
lead="All requests authenticate with a bearer token. Keys are scoped per environment and never expire unless rotated."
>
<CodeBlock
lang="http"
caption="every request"
code={`Authorization: Bearer sk_live_8f2c...e10`}
/>
<div className="portal-docs__keytable">
<div className="portal-docs__keyrow">
<Chip tone="green" size="sm" showDot>
sk_live_
</Chip>
<span>Production keys billed, rate-limited per your plan.</span>
</div>
<div className="portal-docs__keyrow">
<Chip tone="amber" size="sm" showDot>
sk_test_
</Chip>
<span>Sandbox keys free, return synthetic fixtures.</span>
</div>
</div>
</DocsSection>
);
}
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { docsContentFor } from "@portal/mocks/docs";
import { ComponentsSection } from "@portal/components/docs/ComponentsSection";
import "@portal/views/DeveloperDocs.css";
const meta: Meta<typeof ComponentsSection> = {
title: "Portal/DeveloperDocs/ComponentsSection",
component: ComponentsSection,
parameters: { layout: "padded" },
args: { components: docsContentFor("pro").components },
decorators: [
(S) => (
<div style={{ maxWidth: "46rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof ComponentsSection>;
export const Default: Story = {};
@@ -0,0 +1,43 @@
import { Card, Chip, CodeBlock } from "@shared/components";
import type { EmbedComponent } from "@portal/api/docs";
import { DocsSection } from "@portal/components/docs/DocsSection";
export function ComponentsSection({
components,
}: {
components: EmbedComponent[];
}) {
return (
<DocsSection
id="component-library"
eyebrow="COMPONENTS"
title="Drop-in viewers"
lead="Embeddable UI for review queues and document inspection. Bring your own styles or use the shipped theme."
>
<div className="portal-docs__component-grid">
{components.map((c) => (
<Card key={c.name} padding="default">
<div className="portal-docs__component-head">
<code className="portal-docs__component-name">{c.name}</code>
<Chip tone="purple" size="sm">
{c.tag}
</Chip>
</div>
<p className="portal-docs__component-blurb">{c.blurb}</p>
</Card>
))}
</div>
<CodeBlock
lang="typescript"
caption="embed the viewer"
code={`import { DocumentViewer } from "@stirling/react";
<DocumentViewer
documentId={doc.id}
endpoint="/v1/invoice"
onFieldEdit={(field, value) => save(field, value)}
/>`}
/>
</DocsSection>
);
}
@@ -0,0 +1,41 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { buildDocsNav } from "@portal/mocks/docs";
import { DocsNav, DocsNavSkeleton } from "@portal/components/docs/DocsNav";
import "@portal/views/DeveloperDocs.css";
const meta: Meta<typeof DocsNav> = {
title: "Portal/DeveloperDocs/DocsNav",
component: DocsNav,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "15rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof DocsNav>;
const sections = buildDocsNav();
export const Default: Story = {
render: () => {
const [active, setActive] = useState("quickstart");
return <DocsNav sections={sections} active={active} onSelect={setActive} />;
},
};
/** Webhooks ("Beta") and Agent skills ("New") exercise both badge tones. */
export const BadgedLeafActive: Story = {
render: () => {
const [active, setActive] = useState("skill-catalog");
return <DocsNav sections={sections} active={active} onSelect={setActive} />;
},
};
export const Loading: StoryObj = {
render: () => <DocsNavSkeleton />,
};
@@ -0,0 +1,72 @@
import { Skeleton, StatusBadge } from "@shared/components";
import type { DocsNavSection } from "@portal/api/docs";
/** Left-hand documentation nav tree; each leaf selects an in-page section. */
export function DocsNav({
sections,
active,
onSelect,
}: {
sections: DocsNavSection[];
active: string;
onSelect: (id: string) => void;
}) {
return (
<nav className="portal-docs__nav" aria-label="Documentation">
{sections.map((section) => (
<div key={section.id} className="portal-docs__nav-group">
<div className="portal-docs__nav-head">
<span className="portal-docs__nav-icon" aria-hidden>
{section.icon}
</span>
{section.label}
</div>
<ul className="portal-docs__nav-list">
{section.items.map((item) => {
const isActive = item.id === active;
return (
<li key={item.id}>
<button
type="button"
className={
"portal-docs__nav-link" + (isActive ? " is-active" : "")
}
aria-current={isActive ? "page" : undefined}
onClick={() => onSelect(item.id)}
>
<span className="portal-docs__nav-label">{item.label}</span>
{item.badge && (
<StatusBadge
tone={item.badge === "New" ? "success" : "info"}
size="sm"
>
{item.badge}
</StatusBadge>
)}
</button>
</li>
);
})}
</ul>
</div>
))}
</nav>
);
}
export function DocsNavSkeleton() {
return (
<nav className="portal-docs__nav" aria-hidden>
{Array.from({ length: 4 }).map((_, gi) => (
<div key={gi} className="portal-docs__nav-group">
<Skeleton width="7rem" height="0.75rem" />
<div className="portal-docs__nav-list">
{Array.from({ length: 3 }).map((_, li) => (
<Skeleton key={li} width="80%" height="0.875rem" />
))}
</div>
</div>
))}
</nav>
);
}
@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Card } from "@shared/components";
import { DocsSection } from "@portal/components/docs/DocsSection";
import "@portal/views/DeveloperDocs.css";
const meta: Meta<typeof DocsSection> = {
title: "Portal/DeveloperDocs/DocsSection",
component: DocsSection,
parameters: { layout: "padded" },
args: {
id: "example",
eyebrow: "API REFERENCE",
title: "Section heading",
lead: "A short lead paragraph introducing the section's content.",
children: <Card padding="default">Section body</Card>,
},
decorators: [
(S) => (
<div style={{ maxWidth: "46rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof DocsSection>;
export const Default: Story = {};
export const WithoutLead: Story = {
args: { lead: undefined },
};
@@ -0,0 +1,25 @@
import type { ReactNode } from "react";
/** Eyebrow + title + lead heading wrapper shared by every docs content pane. */
export function DocsSection({
id,
eyebrow,
title,
lead,
children,
}: {
id: string;
eyebrow: string;
title: string;
lead?: string;
children: ReactNode;
}) {
return (
<section id={id} className="portal-docs__section">
<div className="portal-docs__section-eyebrow">{eyebrow}</div>
<h1 className="portal-docs__section-title">{title}</h1>
{lead && <p className="portal-docs__section-lead">{lead}</p>}
{children}
</section>
);
}
@@ -0,0 +1,20 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { EndpointReferenceSection } from "@portal/components/docs/EndpointReferenceSection";
import "@portal/views/DeveloperDocs.css";
const meta: Meta<typeof EndpointReferenceSection> = {
title: "Portal/DeveloperDocs/EndpointReferenceSection",
component: EndpointReferenceSection,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "46rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof EndpointReferenceSection>;
export const Default: Story = {};
@@ -0,0 +1,72 @@
import { useMemo, useState } from "react";
import {
MethodBadge,
Tabs,
type HttpMethod,
type TabItem,
} from "@shared/components";
import { VERTICALS, ALL_ENDPOINTS } from "@shared/data/endpoints";
import { DocsSection } from "@portal/components/docs/DocsSection";
type VerticalFilter = "all" | (typeof VERTICALS)[number]["key"];
export function EndpointReferenceSection() {
const [filter, setFilter] = useState<VerticalFilter>("all");
const tabItems: TabItem<VerticalFilter>[] = [
{ key: "all", label: "All", count: ALL_ENDPOINTS.length },
...VERTICALS.map<TabItem<VerticalFilter>>((v) => ({
key: v.key,
label: v.label,
count: v.endpoints.length,
dotColor: v.color,
accentColor: v.color,
})),
];
const shown = useMemo(
() =>
filter === "all" ? VERTICALS : VERTICALS.filter((v) => v.key === filter),
[filter],
);
return (
<DocsSection
id="endpoints"
eyebrow="API REFERENCE"
title="Endpoints"
lead="Every document type is a typed endpoint. POST a file, receive schema-validated JSON. Filter by vertical below."
>
<Tabs
items={tabItems}
activeKey={filter}
onChange={setFilter}
ariaLabel="Filter endpoints by vertical"
/>
<div className="portal-docs__endpoints">
{shown.map((v) => (
<div key={v.key} className="portal-docs__endpoint-group">
<div className="portal-docs__endpoint-grouphead">
<span
className="portal-docs__endpoint-dot"
style={{ background: v.color }}
aria-hidden
/>
{v.label}
</div>
{v.endpoints.map((e) => (
<div key={e.endpoint} className="portal-docs__endpoint-row">
<MethodBadge method={"POST" as HttpMethod} />
<code className="portal-docs__endpoint-path">{e.endpoint}</code>
<span className="portal-docs__endpoint-name">{e.name}</span>
<span className="portal-docs__endpoint-fields">
{Object.keys(e.schema).length} fields
</span>
</div>
))}
</div>
))}
</div>
</DocsSection>
);
}
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { docsContentFor } from "@portal/mocks/docs";
import { ErrorsSection } from "@portal/components/docs/ErrorsSection";
import "@portal/views/DeveloperDocs.css";
const meta: Meta<typeof ErrorsSection> = {
title: "Portal/DeveloperDocs/ErrorsSection",
component: ErrorsSection,
parameters: { layout: "padded" },
args: { errors: docsContentFor("pro").errors },
decorators: [
(S) => (
<div style={{ maxWidth: "46rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof ErrorsSection>;
export const Default: Story = {};
@@ -0,0 +1,38 @@
import { CodeBlock, StatusBadge } from "@shared/components";
import type { ApiErrorRow } from "@portal/api/docs";
import { DocsSection } from "@portal/components/docs/DocsSection";
export function ErrorsSection({ errors }: { errors: ApiErrorRow[] }) {
return (
<DocsSection
id="errors"
eyebrow="API REFERENCE"
title="Errors"
lead="Errors return a stable machine-readable code plus a human message. The 4xx body always includes a request_id for support."
>
<div className="portal-docs__errors">
{errors.map((e) => (
<div key={e.code} className="portal-docs__error-row">
<StatusBadge
tone={e.tone === "red" ? "danger" : "warning"}
size="sm"
>
{e.code}
</StatusBadge>
<span>{e.meaning}</span>
</div>
))}
</div>
<CodeBlock
lang="json"
caption="422 Unprocessable Entity"
code={`{
"error": "schema_validation_failed",
"message": "Field 'total' could not be located",
"request_id": "req_3f8a91c2",
"endpoint": "/v1/invoice"
}`}
/>
</DocsSection>
);
}
@@ -0,0 +1,24 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { docsContentFor } from "@portal/mocks/docs";
import { GettingStartedSection } from "@portal/components/docs/GettingStartedSection";
import "@portal/views/DeveloperDocs.css";
const { quickstartSamples, quickstartResponse } = docsContentFor("pro");
const meta: Meta<typeof GettingStartedSection> = {
title: "Portal/DeveloperDocs/GettingStartedSection",
component: GettingStartedSection,
parameters: { layout: "padded" },
args: { samples: quickstartSamples, response: quickstartResponse },
decorators: [
(S) => (
<div style={{ maxWidth: "46rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof GettingStartedSection>;
export const Default: Story = {};
@@ -0,0 +1,66 @@
import { Card, CodeBlock } from "@shared/components";
import type { CodeSample } from "@portal/api/docs";
import { DocsSection } from "@portal/components/docs/DocsSection";
import { LangSnippet } from "@portal/components/docs/LangSnippet";
export function GettingStartedSection({
samples,
response,
}: {
samples: CodeSample[];
response: string;
}) {
return (
<DocsSection
id="quickstart"
eyebrow="GETTING STARTED"
title="Quickstart"
lead="Send your first document to a typed endpoint and get structured JSON back in three steps. No model training, no prompt engineering."
>
<ol className="portal-docs__steps">
<li className="portal-docs__step">
<span className="portal-docs__step-mark">1</span>
<div className="portal-docs__step-body">
<h3>Issue an API key</h3>
<p>
Create a scoped key from the Infrastructure tab. Keys carry rate
limits and an optional IP allowlist. Export it into your shell:
</p>
<CodeBlock
lang="bash"
code={`export STIRLING_API_KEY="sk_live_8f2c...e10"`}
/>
</div>
</li>
<li className="portal-docs__step">
<span className="portal-docs__step-mark">2</span>
<div className="portal-docs__step-body">
<h3>Send a document</h3>
<p>
POST a file to any typed endpoint. The endpoint determines the
schema you get back here, the invoice extractor.
</p>
<LangSnippet samples={samples} caption="extract an invoice" />
</div>
</li>
<li className="portal-docs__step">
<span className="portal-docs__step-mark">3</span>
<div className="portal-docs__step-body">
<h3>Read the structured result</h3>
<p>
Every response is validated against the endpoint schema, with a
confidence score and per-field provenance.
</p>
<CodeBlock lang="json" code={response} caption="200 OK" />
</div>
</li>
</ol>
<Card className="portal-docs__callout" accent="blue" padding="loose">
<strong>Next:</strong> wire the same call into a pipeline to chain
validation, redaction, and delivery or expose it to an agent over MCP.
See <em>Playbooks</em> for copy-paste recipes.
</Card>
</DocsSection>
);
}
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { docsContentFor } from "@portal/mocks/docs";
import { LangSnippet } from "@portal/components/docs/LangSnippet";
import "@portal/views/DeveloperDocs.css";
const { quickstartSamples } = docsContentFor("pro");
const meta: Meta<typeof LangSnippet> = {
title: "Portal/DeveloperDocs/LangSnippet",
component: LangSnippet,
parameters: { layout: "padded" },
args: { samples: quickstartSamples, caption: "extract an invoice" },
decorators: [
(S) => (
<div style={{ maxWidth: "44rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof LangSnippet>;
export const Default: Story = {};
export const SingleLanguage: Story = {
args: { samples: quickstartSamples.slice(0, 1), caption: undefined },
};
@@ -0,0 +1,27 @@
import { useState } from "react";
import { CodeBlock, Tabs, type TabItem } from "@shared/components";
import type { CodeSample } from "@portal/api/docs";
/** Tabbed multi-language snippet; CodeBlock owns per-language copy. */
export function LangSnippet({
samples,
caption,
}: {
samples: CodeSample[];
caption?: string;
}) {
const [active, setActive] = useState(samples[0]?.key ?? "");
const current = samples.find((s) => s.key === active) ?? samples[0];
const items: TabItem<string>[] = samples.map((s) => ({
key: s.key,
label: s.label,
}));
return (
<div className="portal-docs__snippet">
<Tabs items={items} activeKey={active} onChange={setActive} />
{current && (
<CodeBlock code={current.code} lang={current.lang} caption={caption} />
)}
</div>
);
}
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { docsContentFor } from "@portal/mocks/docs";
import { PlaybooksSection } from "@portal/components/docs/PlaybooksSection";
import "@portal/views/DeveloperDocs.css";
const meta: Meta<typeof PlaybooksSection> = {
title: "Portal/DeveloperDocs/PlaybooksSection",
component: PlaybooksSection,
parameters: { layout: "padded" },
args: { playbooks: docsContentFor("pro").playbooks },
decorators: [
(S) => (
<div style={{ maxWidth: "46rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof PlaybooksSection>;
export const Default: Story = {};
@@ -0,0 +1,42 @@
import { Button, Card, Chip } from "@shared/components";
import type { Playbook } from "@portal/api/docs";
import { DocsSection } from "@portal/components/docs/DocsSection";
export function PlaybooksSection({ playbooks }: { playbooks: Playbook[] }) {
return (
<DocsSection
id="recipes"
eyebrow="PLAYBOOKS"
title="Recipes"
lead="End-to-end patterns that chain sources, operations, and destinations. Each maps to a pipeline you can clone."
>
<div className="portal-docs__playbook-grid">
{playbooks.map((p) => (
<Card key={p.title} accent={p.accent} padding="loose" interactive>
<h3 className="portal-docs__playbook-title">{p.title}</h3>
<p className="portal-docs__playbook-blurb">{p.blurb}</p>
<div className="portal-docs__playbook-flow">
{p.steps.map((step, i) => (
<span key={step} className="portal-docs__playbook-step">
<Chip size="sm" tone="neutral">
{step}
</Chip>
{i < p.steps.length - 1 && (
<span className="portal-docs__playbook-arrow" aria-hidden>
</span>
)}
</span>
))}
</div>
{/* TODO(backend): POST /v1/pipelines/clone-from-playbook to seed a
draft pipeline from this recipe, then route to the composer. */}
<Button variant="outline" accent={p.accent} size="sm">
Clone recipe
</Button>
</Card>
))}
</div>
</DocsSection>
);
}
@@ -0,0 +1,31 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { docsContentFor } from "@portal/mocks/docs";
import { RateLimitsSection } from "@portal/components/docs/RateLimitsSection";
import "@portal/views/DeveloperDocs.css";
const meta: Meta<typeof RateLimitsSection> = {
title: "Portal/DeveloperDocs/RateLimitsSection",
component: RateLimitsSection,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "46rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof RateLimitsSection>;
export const Free: Story = {
args: { rateLimit: docsContentFor("free").rateLimit },
};
export const Pro: Story = {
args: { rateLimit: docsContentFor("pro").rateLimit },
};
export const Enterprise: Story = {
args: { rateLimit: docsContentFor("enterprise").rateLimit },
};
@@ -0,0 +1,38 @@
import { Card, CodeBlock } from "@shared/components";
import type { RateLimit } from "@portal/api/docs";
import { DocsSection } from "@portal/components/docs/DocsSection";
export function RateLimitsSection({ rateLimit }: { rateLimit: RateLimit }) {
return (
<DocsSection
id="rate-limits"
eyebrow="GETTING STARTED"
title="Rate limits & quotas"
lead="Limits scale with your plan. A 429 response includes a Retry-After header; the SDKs back off automatically."
>
<div className="portal-docs__limits">
<Card padding="default">
<div className="portal-docs__limit-label">Requests / minute</div>
<div className="portal-docs__limit-value">{rateLimit.rpm}</div>
</Card>
<Card padding="default">
<div className="portal-docs__limit-label">Burst</div>
<div className="portal-docs__limit-value">{rateLimit.burst}</div>
</Card>
<Card padding="default">
<div className="portal-docs__limit-label">Concurrency</div>
<div className="portal-docs__limit-value">
{rateLimit.concurrency}
</div>
</Card>
</div>
<CodeBlock
lang="http"
caption="429 Too Many Requests"
code={`HTTP/1.1 429 Too Many Requests
Retry-After: 2
X-RateLimit-Remaining: 0`}
/>
</DocsSection>
);
}
@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { docsContentFor } from "@portal/mocks/docs";
import { SdksSection } from "@portal/components/docs/SdksSection";
import "@portal/views/DeveloperDocs.css";
const { sdks } = docsContentFor("pro");
const meta: Meta<typeof SdksSection> = {
title: "Portal/DeveloperDocs/SdksSection",
component: SdksSection,
parameters: { layout: "padded" },
args: { sdks },
decorators: [
(S) => (
<div style={{ maxWidth: "46rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof SdksSection>;
/** Full matrix — GA clients carry no badge; Beta and Deprecated are flagged. */
export const Default: Story = {};
export const GaOnly: Story = {
args: { sdks: sdks.filter((s) => s.status === "ga") },
};
@@ -0,0 +1,44 @@
import { Card, CodeBlock, StatusBadge } from "@shared/components";
import type { Sdk, SdkStatus } from "@portal/api/docs";
import { DocsSection } from "@portal/components/docs/DocsSection";
/** GA clients carry no badge; only non-stable maturity is called out. */
const STATUS_BADGE: Partial<
Record<SdkStatus, { label: string; tone: "info" | "warning" }>
> = {
beta: { label: "Beta", tone: "info" },
deprecated: { label: "Deprecated", tone: "warning" },
};
export function SdksSection({ sdks }: { sdks: Sdk[] }) {
return (
<DocsSection
id="sdk-overview"
eyebrow="SDKS"
title="Official SDKs"
lead="First-party clients with typed responses, automatic retries, and streaming uploads. All track the same endpoint catalogue."
>
<div className="portal-docs__sdk-grid">
{sdks.map((sdk) => {
const badge = STATUS_BADGE[sdk.status];
return (
<Card key={sdk.name} padding="default" interactive>
<div className="portal-docs__sdk-head">
<span className="portal-docs__sdk-icon" aria-hidden>
{sdk.icon}
</span>
<h3 className="portal-docs__sdk-name">{sdk.name}</h3>
{badge && (
<StatusBadge tone={badge.tone} size="sm">
{badge.label}
</StatusBadge>
)}
</div>
<CodeBlock lang={sdk.lang} code={sdk.install} maxHeight={80} />
</Card>
);
})}
</div>
</DocsSection>
);
}
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { docsContentFor } from "@portal/mocks/docs";
import { SkillsSection } from "@portal/components/docs/SkillsSection";
import "@portal/views/DeveloperDocs.css";
const meta: Meta<typeof SkillsSection> = {
title: "Portal/DeveloperDocs/SkillsSection",
component: SkillsSection,
parameters: { layout: "padded" },
args: { skills: docsContentFor("pro").skills },
decorators: [
(S) => (
<div style={{ maxWidth: "46rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof SkillsSection>;
export const Default: Story = {};
@@ -0,0 +1,29 @@
import { Card } from "@shared/components";
import type { AgentSkill } from "@portal/api/docs";
import { DocsSection } from "@portal/components/docs/DocsSection";
export function SkillsSection({ skills }: { skills: AgentSkill[] }) {
return (
<DocsSection
id="skill-catalog"
eyebrow="SKILLS"
title="Agent skills"
lead="Bundled, named capabilities your agent invokes as a single tool. Each skill is a deterministic op chain with evals attached."
>
<div className="portal-docs__skill-grid">
{skills.map((s) => (
<Card key={s.name} padding="default" interactive>
<div className="portal-docs__skill-head">
<span className="portal-docs__skill-glyph" aria-hidden>
</span>
<h3 className="portal-docs__skill-name">{s.name}</h3>
</div>
<p className="portal-docs__skill-blurb">{s.blurb}</p>
<code className="portal-docs__skill-ops">{s.ops}</code>
</Card>
))}
</div>
</DocsSection>
);
}
@@ -0,0 +1,20 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { WebhooksSection } from "@portal/components/docs/WebhooksSection";
import "@portal/views/DeveloperDocs.css";
const meta: Meta<typeof WebhooksSection> = {
title: "Portal/DeveloperDocs/WebhooksSection",
component: WebhooksSection,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "46rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof WebhooksSection>;
export const Default: Story = {};
@@ -0,0 +1,33 @@
import { Card, CodeBlock } from "@shared/components";
import { DocsSection } from "@portal/components/docs/DocsSection";
export function WebhooksSection() {
return (
<DocsSection
id="webhooks"
eyebrow="API REFERENCE"
title="Webhooks"
lead="Subscribe to document.processed, pipeline.completed, and quota.threshold events. Payloads are signed with HMAC-SHA256."
>
<CodeBlock
lang="json"
caption="document.processed"
code={`{
"event": "document.processed",
"id": "evt_91ac3f",
"created": "2026-06-15T09:31:04Z",
"data": {
"endpoint": "/v1/invoice",
"document_id": "doc_77b2",
"confidence": 0.98
}
}`}
/>
<Card className="portal-docs__callout" accent="amber" padding="loose">
Verify the <code>Stirling-Signature</code> header against your signing
secret before trusting a payload. SDKs ship a{" "}
<code>verifyWebhook()</code> helper.
</Card>
</DocsSection>
);
}
@@ -0,0 +1,68 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ApiKeyCard } from "@portal/components/infrastructure/ApiKeyCard";
import type { ApiKey } from "@portal/api/infrastructure";
import "@portal/views/Infrastructure.css";
const BASE: ApiKey = {
id: "key-1",
name: "Production · ingest",
prefix: "sk_live_a3f8…",
created: "Mar 2, 2026",
lastUsed: "2m ago",
status: "active",
rateLimit: 1200,
permissions: ["Read", "Write"],
allowedIps: ["52.14.0.0/16", "18.221.0.0/16"],
usageToday: 84210,
usageMonth: 2410933,
};
const meta: Meta<typeof ApiKeyCard> = {
title: "Portal/Infrastructure/ApiKeyCard",
component: ApiKeyCard,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "44rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof ApiKeyCard>;
export const Active: Story = { args: { apiKey: BASE } };
export const RotateSoon: Story = {
args: {
apiKey: {
...BASE,
name: "Ops · admin (legacy)",
status: "rotate-soon",
permissions: ["Read", "Write", "Admin"],
allowedIps: ["203.0.113.7/32"],
usageToday: 0,
},
},
};
export const Revoked: Story = {
args: {
apiKey: {
...BASE,
name: "Sandbox · webhook tester",
prefix: "sk_test_2c4a…",
status: "revoked",
lastUsed: "never",
permissions: ["Read"],
allowedIps: [],
usageToday: 0,
usageMonth: 0,
},
},
};
export const NoIpAllowlist: Story = {
args: { apiKey: { ...BASE, allowedIps: [] } },
};
@@ -0,0 +1,97 @@
import { useState } from "react";
import { Card, Chip, StatusBadge } from "@shared/components";
import type { ApiKey } from "@portal/api/infrastructure";
import {
KEY_LABEL,
KEY_TONE,
} from "@portal/components/infrastructure/infraFormat";
/** Collapsible row for a single API key: header summary + expandable detail grid. */
export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
const [open, setOpen] = useState(false);
return (
<Card padding="default" className="portal-infra__key">
<button
type="button"
className="portal-infra__key-head"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
>
<span className="portal-infra__key-id">
<span className="portal-infra__cell-strong">{apiKey.name}</span>
<code className="portal-infra__cell-code">{apiKey.prefix}</code>
</span>
<span className="portal-infra__key-head-right">
<StatusBadge tone={KEY_TONE[apiKey.status]} size="sm">
{KEY_LABEL[apiKey.status]}
</StatusBadge>
<span
className={"portal-infra__chevron" + (open ? " is-open" : "")}
aria-hidden
>
</span>
</span>
</button>
{open && (
<div className="portal-infra__key-body">
<dl className="portal-infra__kv">
<div>
<dt>Created</dt>
<dd>{apiKey.created}</dd>
</div>
<div>
<dt>Last used</dt>
<dd>{apiKey.lastUsed}</dd>
</div>
<div>
<dt>Rate limit</dt>
<dd className="portal-infra__mono">
{apiKey.rateLimit.toLocaleString()} req/min
</dd>
</div>
<div>
<dt>Usage today</dt>
<dd className="portal-infra__mono">
{apiKey.usageToday.toLocaleString()}
</dd>
</div>
<div>
<dt>Usage this month</dt>
<dd className="portal-infra__mono">
{apiKey.usageMonth.toLocaleString()}
</dd>
</div>
<div>
<dt>Permissions</dt>
<dd className="portal-infra__chips">
{apiKey.permissions.map((p) => (
<Chip key={p} tone="blue" size="sm">
{p}
</Chip>
))}
</dd>
</div>
<div className="portal-infra__kv-wide">
<dt>Allowed IPs</dt>
<dd className="portal-infra__chips">
{apiKey.allowedIps.length === 0 ? (
<span className="portal-infra__muted">
Any IP (no allowlist)
</span>
) : (
apiKey.allowedIps.map((ip) => (
<Chip key={ip} tone="neutral" size="sm">
<span className="portal-infra__mono">{ip}</span>
</Chip>
))
)}
</dd>
</div>
</dl>
</div>
)}
</Card>
);
}
@@ -0,0 +1,44 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import { ApiKeysTab } from "@portal/components/infrastructure/ApiKeysTab";
import "@portal/views/Infrastructure.css";
const meta: Meta<typeof ApiKeysTab> = {
title: "Portal/Infrastructure/ApiKeysTab",
component: ApiKeysTab,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "60rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof ApiKeysTab>;
export const Default: Story = {};
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/infrastructure/api-keys", async () => {
await delay("infinite");
return HttpResponse.json([]);
}),
],
},
},
};
export const Empty: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/infrastructure/api-keys", () => HttpResponse.json([])),
],
},
},
};
@@ -0,0 +1,61 @@
import { useState } from "react";
import { Button, EmptyState, Skeleton } from "@shared/components";
import { useTier } from "@portal/contexts/TierContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import { fetchApiKeys, type ApiKey } from "@portal/api/infrastructure";
import { ApiKeyCard } from "@portal/components/infrastructure/ApiKeyCard";
import { CreateKeyModal } from "@portal/components/infrastructure/CreateKeyModal";
import { SectionHeader } from "@portal/components/infrastructure/SectionHeader";
export function ApiKeysTab() {
const { tier } = useTier();
const [modalOpen, setModalOpen] = useState(false);
const state = useAsync<ApiKey[]>(() => fetchApiKeys(tier), [tier]);
const { data: keys } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
return (
<div className="portal-infra__stack">
<div className="portal-infra__bar">
<SectionHeader
title="API keys"
sub="Scoped credentials with per-key rate limits, permissions, and IP allowlists."
/>
<Button
variant="gradient"
size="sm"
onClick={() => setModalOpen(true)}
leadingIcon={<span aria-hidden>+</span>}
>
Create key
</Button>
</div>
{isLoading && (
<div className="portal-infra__stack" aria-hidden>
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} height="3.25rem" />
))}
</div>
)}
{isEmpty && (
<EmptyState
size="compact"
title="No API keys yet"
description="Create a scoped key to start calling the Stirling API."
/>
)}
{keys && keys.length > 0 && (
<div className="portal-infra__keys">
{keys.map((k) => (
<ApiKeyCard key={k.id} apiKey={k} />
))}
</div>
)}
<CreateKeyModal open={modalOpen} onClose={() => setModalOpen(false)} />
</div>
);
}
@@ -0,0 +1,49 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import { AuditTab } from "@portal/components/infrastructure/AuditTab";
import "@portal/views/Infrastructure.css";
const meta: Meta<typeof AuditTab> = {
title: "Portal/Infrastructure/AuditTab",
component: AuditTab,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "72rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof AuditTab>;
export const Default: Story = {};
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/infrastructure/audit-log", async () => {
await delay("infinite");
return HttpResponse.json({ summary: {}, events: [] });
}),
],
},
},
};
export const Empty: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/infrastructure/audit-log", () =>
HttpResponse.json({
summary: { totalEvents: 0, processing: 0, elevation: 0, config: 0 },
events: [],
}),
),
],
},
},
};
@@ -0,0 +1,150 @@
import { useMemo, useState } from "react";
import {
Card,
EmptyState,
MetricCard,
StatusBadge,
Table,
Tabs,
type TabItem,
type TableColumn,
} from "@shared/components";
import { useTier } from "@portal/contexts/TierContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import {
fetchAuditLog,
type AuditCategory,
type AuditEvent,
type AuditLogResponse,
} from "@portal/api/infrastructure";
import { SectionHeader } from "@portal/components/infrastructure/SectionHeader";
import { TableSkeleton } from "@portal/components/infrastructure/TableSkeleton";
import {
AUDIT_CAT_LABEL,
AUDIT_CAT_TONE,
AUDIT_TONE,
titleCase,
} from "@portal/components/infrastructure/infraFormat";
type AuditFilter = "all" | AuditCategory;
const AUDIT_FILTERS: TabItem<AuditFilter>[] = [
{ key: "all", label: "All" },
{ key: "auth", label: "Auth" },
{ key: "config", label: "Config" },
{ key: "elevation", label: "Elevation" },
{ key: "processing", label: "Processing" },
{ key: "security", label: "Security" },
];
const cols: TableColumn<AuditEvent>[] = [
{
key: "timestamp",
header: "Timestamp",
render: (e) => <span className="portal-infra__mono">{e.timestamp}</span>,
},
{
key: "event",
header: "Event",
render: (e) => (
<div className="portal-infra__event">
<StatusBadge tone={AUDIT_CAT_TONE[e.category]} size="sm">
{AUDIT_CAT_LABEL[e.category]}
</StatusBadge>
<span>{e.action}</span>
</div>
),
},
{
key: "actor",
header: "Actor",
render: (e) => <span className="portal-infra__mono">{e.actor}</span>,
},
{ key: "target", header: "Target", render: (e) => e.target },
{
key: "status",
header: "Status",
render: (e) => (
<StatusBadge tone={AUDIT_TONE[e.status]} size="sm">
{titleCase(e.status)}
</StatusBadge>
),
},
{
key: "latency",
header: "Latency",
align: "right",
render: (e) => <span className="portal-infra__mono">{e.latencyMs} ms</span>,
},
];
export function AuditTab() {
const { tier } = useTier();
const [filter, setFilter] = useState<AuditFilter>("all");
const state = useAsync<AuditLogResponse>(() => fetchAuditLog(tier), [tier]);
const { data } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
const rows = useMemo(() => {
if (!data) return [];
if (filter === "all") return data.events;
return data.events.filter((e) => e.category === filter);
}, [data, filter]);
return (
<div className="portal-infra__stack">
<SectionHeader
title="Audit logs"
sub="Every authentication, configuration, and processing event across your workspace."
/>
{data && (
<section className="portal-infra__metrics">
<MetricCard
label="Total events · 24h"
value={data.summary.totalEvents.toLocaleString()}
/>
<MetricCard
label="Processing"
value={data.summary.processing.toLocaleString()}
/>
<MetricCard
label="Elevation"
value={data.summary.elevation.toLocaleString()}
/>
<MetricCard
label="Config"
value={data.summary.config.toLocaleString()}
/>
</section>
)}
<Tabs<AuditFilter>
items={AUDIT_FILTERS}
activeKey={filter}
onChange={setFilter}
variant="pill"
ariaLabel="Filter audit events by category"
/>
<Card padding="none">
{isLoading && <TableSkeleton rows={6} cols={6} />}
{isEmpty && (
<EmptyState
size="compact"
title="No audit events"
description="Workspace activity will appear here as it happens."
/>
)}
{!isEmpty && data && (
<Table
columns={cols}
rows={rows}
rowKey={(e) => e.id}
empty="No events in this category."
/>
)}
</Card>
</div>
);
}
@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CreateKeyModal } from "@portal/components/infrastructure/CreateKeyModal";
import "@portal/views/Infrastructure.css";
const meta: Meta<typeof CreateKeyModal> = {
title: "Portal/Infrastructure/CreateKeyModal",
component: CreateKeyModal,
parameters: { layout: "fullscreen" },
args: { open: true, onClose: () => console.log("close") },
};
export default meta;
type Story = StoryObj<typeof CreateKeyModal>;
export const Form: Story = {};
export const Closed: Story = { args: { open: false } };
@@ -0,0 +1,137 @@
import { useState } from "react";
import {
Banner,
Button,
Checkbox,
CodeBlock,
FormField,
Input,
Modal,
} from "@shared/components";
import type { ApiKeyPermission } from "@portal/api/infrastructure";
const PERMISSION_OPTS: ApiKeyPermission[] = ["Read", "Write", "Admin"];
// Shown once after a key is created. TODO(backend): use the one-time secret
// returned by POST /v1/infrastructure/api-keys — it is never persisted server-side.
const DEMO_NEW_KEY_SECRET = "sk_live_demo_key_rotate_in_prod";
export function CreateKeyModal({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const [name, setName] = useState("");
const [perms, setPerms] = useState<ApiKeyPermission[]>(["Read"]);
const [ips, setIps] = useState("");
const [created, setCreated] = useState(false);
function reset() {
setName("");
setPerms(["Read"]);
setIps("");
setCreated(false);
}
function close() {
onClose();
// Defer reset so the modal doesn't flash empty during its close transition.
setTimeout(reset, 200);
}
function togglePerm(p: ApiKeyPermission) {
setPerms((prev) =>
prev.includes(p) ? prev.filter((x) => x !== p) : [...prev, p],
);
}
function createKey() {
// TODO(backend): POST /v1/infrastructure/api-keys { name, perms, ips }
// and render the one-time secret from the response instead of the fixture.
setCreated(true);
}
return (
<Modal
open={open}
onClose={close}
width="md"
title={created ? "Key created" : "Create API key"}
subtitle={
created
? "Copy this secret now — it won't be shown again."
: "Scope the key to the minimum it needs. You can rotate or revoke at any time."
}
footer={
created ? (
<Button variant="gradient" onClick={close}>
Done
</Button>
) : (
<div className="portal-infra__modal-actions">
<Button variant="ghost" onClick={close}>
Cancel
</Button>
<Button
variant="gradient"
disabled={name.trim() === "" || perms.length === 0}
onClick={createKey}
>
Create key
</Button>
</div>
)
}
>
{created ? (
<div className="portal-infra__stack">
<CodeBlock
code={DEMO_NEW_KEY_SECRET}
lang="bash"
caption="Secret key"
/>
<Banner
tone="warning"
description="Store this in a secrets manager. Stirling only ever stores a hash — there is no way to recover it later."
/>
</div>
) : (
<div className="portal-infra__form">
<FormField label="Key name" required>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Production · ingest"
/>
</FormField>
<FormField label="Permissions">
<div className="portal-infra__perm-row">
{PERMISSION_OPTS.map((p) => (
<Checkbox
key={p}
label={p}
checked={perms.includes(p)}
onChange={() => togglePerm(p)}
/>
))}
</div>
</FormField>
<FormField
label="IP allowlist"
helperText="Comma-separated CIDR ranges. Leave blank to allow any IP."
>
<Input
value={ips}
onChange={(e) => setIps(e.target.value)}
placeholder="52.14.0.0/16, 203.0.113.7/32"
/>
</FormField>
</div>
)}
</Modal>
);
}
@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import { DeploymentsTab } from "@portal/components/infrastructure/DeploymentsTab";
import "@portal/views/Infrastructure.css";
const meta: Meta<typeof DeploymentsTab> = {
title: "Portal/Infrastructure/DeploymentsTab",
component: DeploymentsTab,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "72rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof DeploymentsTab>;
export const Default: Story = {};
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/infrastructure/deployments", async () => {
await delay("infinite");
return HttpResponse.json({ regions: [], recent: [] });
}),
],
},
},
};
export const Empty: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/infrastructure/deployments", () =>
HttpResponse.json({ regions: [], recent: [] }),
),
],
},
},
};
@@ -0,0 +1,206 @@
import {
Card,
Chip,
EmptyState,
ProgressBar,
StatusBadge,
Table,
type TableColumn,
} from "@shared/components";
import { useTier } from "@portal/contexts/TierContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import {
fetchDeployments,
type DeploymentsResponse,
type DeploymentRegion,
type RecentDeployment,
} from "@portal/api/infrastructure";
import { SectionHeader } from "@portal/components/infrastructure/SectionHeader";
import { TableSkeleton } from "@portal/components/infrastructure/TableSkeleton";
import {
DEPLOY_LABEL,
DEPLOY_TONE,
pct,
REGION_TONE,
titleCase,
} from "@portal/components/infrastructure/infraFormat";
const regionCols: TableColumn<DeploymentRegion>[] = [
{
key: "name",
header: "Region",
render: (r) => (
<div className="portal-infra__cell-stack">
<span className="portal-infra__cell-strong">{r.name}</span>
<code className="portal-infra__cell-code">{r.code}</code>
</div>
),
},
{
key: "latency",
header: "Latency",
align: "right",
render: (r) => <span className="portal-infra__mono">{r.latencyMs} ms</span>,
},
{
key: "load",
header: "Load",
width: "9rem",
render: (r) => (
<div className="portal-infra__load">
<ProgressBar value={r.load} thresholded height={6} />
<span className="portal-infra__load-pct">{pct(r.load)}</span>
</div>
),
},
{
key: "status",
header: "Status",
render: (r) => (
<StatusBadge
tone={REGION_TONE[r.status]}
size="sm"
pulse={r.status === "healthy"}
>
{titleCase(r.status)}
</StatusBadge>
),
},
{
key: "version",
header: "Version",
render: (r) => <code className="portal-infra__cell-code">{r.version}</code>,
},
{
key: "uptime",
header: "Uptime",
align: "right",
render: (r) => (
<span className="portal-infra__mono">{pct(r.uptime, 3)}</span>
),
},
{
key: "instances",
header: "Instances",
align: "right",
render: (r) => <span className="portal-infra__mono">{r.instances}</span>,
},
{
key: "throughput",
header: "Throughput",
align: "right",
render: (r) => (
<span className="portal-infra__mono">
{r.throughput.toLocaleString()}/min
</span>
),
},
{
key: "p99",
header: "P99",
align: "right",
render: (r) => <span className="portal-infra__mono">{r.p99Ms} ms</span>,
},
];
const deployCols: TableColumn<RecentDeployment>[] = [
{
key: "version",
header: "Version",
render: (d) => <code className="portal-infra__cell-code">{d.version}</code>,
},
{
key: "environment",
header: "Environment",
render: (d) => (
<Chip
tone={
d.environment === "production"
? "blue"
: d.environment === "canary"
? "purple"
: "neutral"
}
size="sm"
>
{d.environment}
</Chip>
),
},
{ key: "product", header: "Product", render: (d) => d.product },
{
key: "status",
header: "Status",
render: (d) => (
<StatusBadge tone={DEPLOY_TONE[d.status]} size="sm">
{DEPLOY_LABEL[d.status]}
</StatusBadge>
),
},
{
key: "deployedBy",
header: "Deployed by",
render: (d) => <span className="portal-infra__mono">{d.deployedBy}</span>,
},
{
key: "timestamp",
header: "When",
align: "right",
render: (d) => <span className="portal-infra__muted">{d.timestamp}</span>,
},
];
export function DeploymentsTab() {
const { tier } = useTier();
const state = useAsync<DeploymentsResponse>(
() => fetchDeployments(tier),
[tier],
);
const { data } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
return (
<div className="portal-infra__stack">
<section>
<SectionHeader
title="Regions"
sub="Live health for every deployed Stirling region — latency, load, and rollout version."
/>
<Card padding="none">
{isLoading && <TableSkeleton rows={3} cols={9} />}
{isEmpty && (
<EmptyState
size="compact"
title="No regions deployed"
description="Deployed regions appear here once your workspace is provisioned."
/>
)}
{!isEmpty && data && data.regions.length > 0 && (
<Table
columns={regionCols}
rows={data.regions}
rowKey={(r) => r.code}
/>
)}
</Card>
</section>
<section>
<SectionHeader
title="Recent deployments"
sub="The latest rollouts across products and environments."
/>
<Card padding="none">
{isLoading && <TableSkeleton rows={4} cols={6} />}
{data && data.recent.length > 0 && (
<Table
columns={deployCols}
rows={data.recent}
rowKey={(d) => d.id}
/>
)}
</Card>
</section>
</div>
);
}
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { SectionHeader } from "@portal/components/infrastructure/SectionHeader";
import "@portal/views/Infrastructure.css";
const meta: Meta<typeof SectionHeader> = {
title: "Portal/Infrastructure/SectionHeader",
component: SectionHeader,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof SectionHeader>;
export const Default: Story = {
args: {
title: "Regions",
sub: "Live health for every deployed Stirling region — latency, load, and rollout version.",
},
};
export const ShortSub: Story = {
args: { title: "API keys", sub: "Scoped credentials." },
};
@@ -0,0 +1,9 @@
/** Title + sub-line heading shared by every Infrastructure section. */
export function SectionHeader({ title, sub }: { title: string; sub: string }) {
return (
<header className="portal-infra__section-head">
<h2 className="portal-infra__section-title">{title}</h2>
<p className="portal-infra__section-sub">{sub}</p>
</header>
);
}
@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import { SecurityTab } from "@portal/components/infrastructure/SecurityTab";
import "@portal/views/Infrastructure.css";
const meta: Meta<typeof SecurityTab> = {
title: "Portal/Infrastructure/SecurityTab",
component: SecurityTab,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "72rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof SecurityTab>;
export const Default: Story = {};
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/infrastructure/security", async () => {
await delay("infinite");
return HttpResponse.json(null);
}),
],
},
},
};
export const Unavailable: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/infrastructure/security", () =>
HttpResponse.json(null, { status: 503 }),
),
],
},
},
};
@@ -0,0 +1,197 @@
import { useState } from "react";
import {
Banner,
Card,
EmptyState,
RadioGroup,
Skeleton,
StatusBadge,
Table,
type RadioOption,
type TableColumn,
} from "@shared/components";
import { useTier } from "@portal/contexts/TierContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import {
fetchSecurity,
type AccessPolicy,
type DataResidency,
type SecurityConfig,
} from "@portal/api/infrastructure";
import { SectionHeader } from "@portal/components/infrastructure/SectionHeader";
import {
CERT_LABEL,
CERT_TONE,
} from "@portal/components/infrastructure/infraFormat";
const ACCESS_OPTS: RadioOption<AccessPolicy>[] = [
{
value: "stirling",
label: "Stirling-held keys",
description:
"Stirling manages encryption keys. Simplest — zero key ops on your side.",
},
{
value: "byok",
label: "Bring your own key (BYOK)",
description:
"Supply a key from your own KMS. Stirling encrypts with it but can still read.",
},
{
value: "hyok",
label: "Hold your own key (HYOK)",
description: "Keys never leave your KMS. Stirling holds only ciphertext.",
},
];
const RESIDENCY_OPTS: RadioOption<DataResidency>[] = [
{ value: "us", label: "United States", description: "us-east-1 · us-west-2" },
{
value: "eu",
label: "European Union",
description: "eu-west-1 · GDPR data boundary",
},
{ value: "apac", label: "Asia Pacific", description: "ap-southeast-1" },
];
const ipCols: TableColumn<SecurityConfig["ipAllowlist"][number]>[] = [
{ key: "label", header: "Label", render: (e) => e.label },
{
key: "cidr",
header: "CIDR",
render: (e) => <code className="portal-infra__cell-code">{e.cidr}</code>,
},
{
key: "addedBy",
header: "Added by",
render: (e) => <span className="portal-infra__mono">{e.addedBy}</span>,
},
{
key: "added",
header: "Added",
align: "right",
render: (e) => <span className="portal-infra__muted">{e.added}</span>,
},
];
export function SecurityTab() {
const { tier } = useTier();
const state = useAsync<SecurityConfig>(() => fetchSecurity(tier), [tier]);
const { data } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
// Local mirrors so the radios are interactive without a backend round-trip,
// seeded from the fetched config once it lands.
// TODO(backend): PATCH /v1/infrastructure/security { accessPolicy, dataResidency }
const [access, setAccess] = useState<AccessPolicy | null>(null);
const [residency, setResidency] = useState<DataResidency | null>(null);
const accessValue = access ?? data?.accessPolicy ?? "stirling";
const residencyValue = residency ?? data?.dataResidency ?? "us";
if (isLoading) {
return (
<div className="portal-infra__stack" aria-hidden>
<Skeleton height="11rem" />
<Skeleton height="7rem" />
</div>
);
}
if (isEmpty || !data) {
return (
<EmptyState
size="compact"
title="Security posture unavailable"
description="Your workspace's security configuration will appear here."
/>
);
}
return (
<div className="portal-infra__stack">
<section className="portal-infra__split">
<Card padding="loose">
<SectionHeader
title="Document access policy"
sub="Controls who can decrypt processed documents at rest."
/>
<RadioGroup
name="access-policy"
value={accessValue}
onChange={setAccess}
options={ACCESS_OPTS}
/>
{accessValue === "hyok" && (
<Banner
tone="success"
className="portal-infra__banner"
title="Stirling cannot decrypt your documents"
description="With HYOK, encryption keys never leave your KMS. Stirling stores and processes only ciphertext you can revoke at any time."
/>
)}
</Card>
<Card padding="loose">
<SectionHeader
title="Data residency"
sub="Where documents are stored and processed."
/>
<RadioGroup
name="data-residency"
value={residencyValue}
onChange={setResidency}
options={RESIDENCY_OPTS}
/>
</Card>
</section>
<section>
<SectionHeader
title="Compliance"
sub="Attestations and certifications covering the Stirling platform."
/>
<div className="portal-infra__certs">
{data.certs.map((c) => (
<Card key={c.id} padding="default" className="portal-infra__cert">
<div className="portal-infra__cert-head">
<span className="portal-infra__cell-strong">{c.name}</span>
<StatusBadge tone={CERT_TONE[c.status]} size="sm">
{CERT_LABEL[c.status]}
</StatusBadge>
</div>
<p className="portal-infra__cert-detail">{c.detail}</p>
</Card>
))}
</div>
</section>
<section>
<SectionHeader
title="IP allowlist"
sub={
tier === "free"
? "Restrict API access to known IP ranges — available on paid plans."
: "API access is restricted to these CIDR ranges."
}
/>
{tier === "free" ? (
<Banner
tone="info"
title="IP allowlisting is a paid feature"
description="Upgrade to Pro to restrict API access to specific networks."
/>
) : (
<Card padding="none">
<Table
columns={ipCols}
rows={data.ipAllowlist}
rowKey={(e) => e.id}
empty="No IP ranges configured — all IPs allowed."
/>
</Card>
)}
</section>
</div>
);
}
@@ -0,0 +1,80 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import { StorageTab } from "@portal/components/infrastructure/StorageTab";
import type { StorageConfig } from "@portal/api/infrastructure";
import "@portal/views/Infrastructure.css";
const meta: Meta<typeof StorageTab> = {
title: "Portal/Infrastructure/StorageTab",
component: StorageTab,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "72rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof StorageTab>;
export const Default: Story = {};
const OVER_CAP: StorageConfig = {
usedGb: 1920,
quotaGb: 2000,
retention: "180",
providers: [
{
id: "stirling",
name: "Stirling Cloud",
kind: "stirling",
connected: true,
detail: "Primary vault · us-east-1",
usedGb: 1532,
},
{
id: "s3",
name: "Amazon S3",
kind: "s3",
connected: true,
detail: "s3://acme-prod-archive · WORM",
usedGb: 388,
},
{
id: "azure",
name: "Azure Blob",
kind: "azure",
connected: false,
detail: "Not connected",
usedGb: 0,
},
],
};
// Quota nearly exhausted — exercises the danger threshold on the usage bar.
export const OverThreshold: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/infrastructure/storage", () =>
HttpResponse.json(OVER_CAP),
),
],
},
},
};
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/infrastructure/storage", async () => {
await delay("infinite");
return HttpResponse.json(null);
}),
],
},
},
};
@@ -0,0 +1,186 @@
import { useState } from "react";
import {
Button,
Card,
EmptyState,
FormField,
ProgressBar,
Select,
Skeleton,
StatusBadge,
} from "@shared/components";
import { useTier } from "@portal/contexts/TierContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import {
fetchStorage,
type RetentionWindow,
type StorageConfig,
} from "@portal/api/infrastructure";
import { SectionHeader } from "@portal/components/infrastructure/SectionHeader";
import { pct } from "@portal/components/infrastructure/infraFormat";
const RETENTION_OPTS = [
{ value: "30", label: "30 days" },
{ value: "60", label: "60 days" },
{ value: "90", label: "90 days" },
{ value: "180", label: "180 days" },
{ value: "never", label: "Never delete" },
];
const PROVIDER_GLYPH: Record<
StorageConfig["providers"][number]["kind"],
string
> = {
stirling: "◆",
s3: "▣",
azure: "▤",
};
/** Storage fills past this fraction of quota are surfaced in red. */
const USAGE_DANGER_FRAC = 0.8;
export function StorageTab() {
const { tier } = useTier();
const state = useAsync<StorageConfig>(() => fetchStorage(tier), [tier]);
const { data } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
// TODO(backend): PATCH /v1/infrastructure/storage { retention }
const [retention, setRetention] = useState<RetentionWindow | null>(null);
const retentionValue = retention ?? data?.retention ?? "90";
if (isLoading) {
return (
<div className="portal-infra__stack" aria-hidden>
<Skeleton height="6rem" />
<Skeleton height="9rem" />
</div>
);
}
if (isEmpty || !data) {
return (
<EmptyState
size="compact"
title="No storage configured"
description="Connected storage and usage appear here."
/>
);
}
const usedFrac = data.quotaGb > 0 ? data.usedGb / data.quotaGb : 0;
const overThreshold = usedFrac > USAGE_DANGER_FRAC;
return (
<div className="portal-infra__stack">
<section>
<SectionHeader
title="Total usage"
sub="Storage consumed across all connected providers."
/>
<Card padding="loose">
<div className="portal-infra__usage-head">
<span className="portal-infra__usage-value">
{data.usedGb.toLocaleString()} GB
<span className="portal-infra__muted">
{" "}
/ {data.quotaGb.toLocaleString()} GB
</span>
</span>
<StatusBadge tone={overThreshold ? "danger" : "success"} size="sm">
{pct(usedFrac)} used
</StatusBadge>
</div>
<ProgressBar
value={usedFrac}
height={10}
color={
overThreshold
? "linear-gradient(90deg, var(--color-red), color-mix(in srgb, var(--color-red) 70%, white))"
: "linear-gradient(90deg, var(--color-green), color-mix(in srgb, var(--color-green) 70%, white))"
}
label="Storage used"
/>
</Card>
</section>
<section className="portal-infra__split">
<Card padding="loose">
<SectionHeader
title="Connected providers"
sub="Where processed artifacts are written."
/>
<ul className="portal-infra__providers">
{data.providers.map((p) => (
<li key={p.id} className="portal-infra__provider">
<span className="portal-infra__provider-glyph" aria-hidden>
{PROVIDER_GLYPH[p.kind]}
</span>
<span className="portal-infra__provider-text">
<span className="portal-infra__cell-strong">{p.name}</span>
<span className="portal-infra__muted">{p.detail}</span>
</span>
{p.connected ? (
<span className="portal-infra__provider-meta">
<span className="portal-infra__mono">{p.usedGb} GB</span>
<StatusBadge tone="success" size="sm">
Connected
</StatusBadge>
</span>
) : (
// TODO(backend): launch the provider OAuth/credential flow,
// then POST /v1/infrastructure/storage/providers/{id}/connect
<Button variant="outline" size="sm">
Connect
</Button>
)}
</li>
))}
</ul>
</Card>
<Card padding="loose">
<SectionHeader
title="Retention"
sub="How long artifacts are kept before lifecycle deletion."
/>
<FormField label="Default retention window">
<Select
options={RETENTION_OPTS}
value={retentionValue}
onChange={(e) => setRetention(e.target.value as RetentionWindow)}
/>
</FormField>
<div className="portal-infra__lifecycle">
<div className="portal-infra__lifecycle-stage is-active">
<span className="portal-infra__lifecycle-dot" />
<span className="portal-infra__lifecycle-label">Active</span>
<span className="portal-infra__muted">
0{retentionValue === "never" ? "∞" : retentionValue}d
</span>
</div>
<span className="portal-infra__lifecycle-arrow" aria-hidden>
</span>
<div className="portal-infra__lifecycle-stage">
<span className="portal-infra__lifecycle-dot" />
<span className="portal-infra__lifecycle-label">Archived</span>
<span className="portal-infra__muted">cold storage</span>
</div>
<span className="portal-infra__lifecycle-arrow" aria-hidden>
</span>
<div className="portal-infra__lifecycle-stage">
<span className="portal-infra__lifecycle-dot" />
<span className="portal-infra__lifecycle-label">Deleted</span>
<span className="portal-infra__muted">
{retentionValue === "never" ? "never" : "purged"}
</span>
</div>
</div>
</Card>
</section>
</div>
);
}
@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Card } from "@shared/components";
import { TableSkeleton } from "@portal/components/infrastructure/TableSkeleton";
import "@portal/views/Infrastructure.css";
const meta: Meta<typeof TableSkeleton> = {
title: "Portal/Infrastructure/TableSkeleton",
component: TableSkeleton,
parameters: { layout: "padded" },
decorators: [
(S) => (
<Card padding="none" style={{ maxWidth: "60rem" }}>
<S />
</Card>
),
],
};
export default meta;
type Story = StoryObj<typeof TableSkeleton>;
export const Regions: Story = { args: { rows: 3, cols: 9 } };
export const AuditLog: Story = { args: { rows: 6, cols: 6 } };
@@ -0,0 +1,16 @@
import { Skeleton } from "@shared/components";
/** Placeholder grid shown inside a table card while rows are loading. */
export function TableSkeleton({ rows, cols }: { rows: number; cols: number }) {
return (
<div className="portal-infra__table-skel" aria-hidden>
{Array.from({ length: rows }).map((_, r) => (
<div key={r} className="portal-infra__table-skel-row">
{Array.from({ length: cols }).map((_, c) => (
<Skeleton key={c} height="0.75rem" />
))}
</div>
))}
</div>
);
}
@@ -0,0 +1,86 @@
import type { StatusTone } from "@shared/components";
import type {
ApiKeyStatus,
AuditCategory,
AuditStatus,
CertStatus,
DeploymentStatus,
RegionStatus,
} from "@portal/api/infrastructure";
/** Format a 01 fraction as a percentage string. */
export function pct(n: number, digits = 0): string {
return `${(n * 100).toFixed(digits)}%`;
}
/** Capitalise the first letter of a lower-case status word. */
export function titleCase(word: string): string {
return word[0].toUpperCase() + word.slice(1);
}
export const REGION_TONE: Record<RegionStatus, StatusTone> = {
healthy: "success",
degraded: "warning",
down: "danger",
};
export const DEPLOY_TONE: Record<DeploymentStatus, StatusTone> = {
live: "success",
rolling: "info",
"rolled-back": "warning",
queued: "neutral",
};
export const DEPLOY_LABEL: Record<DeploymentStatus, string> = {
live: "Live",
rolling: "Rolling out",
"rolled-back": "Rolled back",
queued: "Queued",
};
export const KEY_TONE: Record<ApiKeyStatus, StatusTone> = {
active: "success",
revoked: "danger",
"rotate-soon": "warning",
};
export const KEY_LABEL: Record<ApiKeyStatus, string> = {
active: "Active",
revoked: "Revoked",
"rotate-soon": "Rotate soon",
};
export const CERT_TONE: Record<CertStatus, StatusTone> = {
certified: "success",
"in-progress": "warning",
"not-started": "neutral",
};
export const CERT_LABEL: Record<CertStatus, string> = {
certified: "Certified",
"in-progress": "In progress",
"not-started": "Not started",
};
export const AUDIT_TONE: Record<AuditStatus, StatusTone> = {
success: "success",
warning: "warning",
danger: "danger",
info: "info",
};
export const AUDIT_CAT_LABEL: Record<AuditCategory, string> = {
auth: "Auth",
config: "Config",
elevation: "Elevation",
processing: "Processing",
security: "Security",
};
export const AUDIT_CAT_TONE: Record<AuditCategory, StatusTone> = {
auth: "info",
config: "neutral",
elevation: "purple",
processing: "success",
security: "warning",
};
@@ -0,0 +1,30 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PipelineCard } from "@portal/components/pipelines/PipelineCard";
import {
DEGRADED_PIPELINE,
HEALTHY_PIPELINE,
} from "@portal/components/pipelines/storyFixtures";
const meta: Meta<typeof PipelineCard> = {
title: "Portal/Pipelines/PipelineCard",
component: PipelineCard,
parameters: { layout: "padded" },
args: { onOpen: () => console.log("open") },
decorators: [
(S) => (
<div style={{ maxWidth: "52rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof PipelineCard>;
export const Healthy: Story = {
args: { pipeline: HEALTHY_PIPELINE },
};
export const DegradedWithDrift: Story = {
args: { pipeline: DEGRADED_PIPELINE },
};
@@ -0,0 +1,109 @@
import { Card, StatTile, StatusBadge } from "@shared/components";
import type { Pipeline, StageSummary } from "@portal/api/pipelines";
import {
STAGE_ACCENT,
STAGE_COLOR_VAR,
} from "@portal/components/pipelines/stageAccent";
import { compact, pct } from "@portal/components/pipelines/format";
/** Compact five-dot stage indicator: a lit dot per stage that has ops. */
function StageDots({ stages }: { stages: StageSummary[] }) {
return (
<span className="portal-pipelines__stage-dots" aria-hidden>
{stages.map((s) => (
<span
key={s.key}
className="portal-pipelines__stage-dot"
style={{
background: s.ops.length
? STAGE_COLOR_VAR[STAGE_ACCENT[s.key]]
: "var(--color-border)",
}}
title={`${s.label}: ${s.ops.length} op${s.ops.length === 1 ? "" : "s"}`}
/>
))}
</span>
);
}
export interface PipelineCardProps {
pipeline: Pipeline;
onOpen: (p: Pipeline) => void;
}
/** Row in the deployed fleet: health, source→stages→destination rail, 24h metrics. */
export function PipelineCard({ pipeline, onOpen }: PipelineCardProps) {
const m = pipeline.metrics;
const degraded = pipeline.status === "degraded";
const errorTone =
m.errorRate >= 0.02
? "danger"
: m.errorRate >= 0.01
? "warning"
: "default";
const driftCount = pipeline.drift.length;
return (
<Card
padding="loose"
interactive
className="portal-pipelines__card"
onClick={() => onOpen(pipeline)}
>
<div className="portal-pipelines__card-head">
<div className="portal-pipelines__card-titles">
<h3 className="portal-pipelines__card-title">{pipeline.name}</h3>
<p className="portal-pipelines__card-blurb">{pipeline.blurb}</p>
</div>
<StatusBadge
tone={degraded ? "warning" : "success"}
size="sm"
pulse={degraded}
>
{degraded ? "Degraded" : "Healthy"}
</StatusBadge>
</div>
<div className="portal-pipelines__card-rail">
<span className="portal-pipelines__rail-chip">{pipeline.source}</span>
<span className="portal-pipelines__rail-arrow" aria-hidden>
</span>
<StageDots stages={pipeline.stages} />
<span className="portal-pipelines__rail-arrow" aria-hidden>
</span>
<span className="portal-pipelines__rail-chip">
{pipeline.destination}
</span>
</div>
<div className="portal-pipelines__metrics">
<StatTile label="Docs / 24h" value={compact(m.docs24h)} />
<StatTile label="Throughput" value={`${m.throughputPerMin}/min`} />
<StatTile
label="Error rate"
value={pct(m.errorRate, 2)}
tone={errorTone}
/>
<StatTile label="P95 latency" value={`${m.p95LatencyMs} ms`} />
<StatTile label="Uptime" value={pct(m.uptime, 2)} />
</div>
<div className="portal-pipelines__card-foot">
<span className="portal-pipelines__card-version">
{pipeline.version} · {pipeline.regions.join(", ")}
</span>
<span className="portal-pipelines__card-golden">
Golden {pipeline.golden.passing}/{pipeline.golden.total}
{driftCount > 0 && (
<span className="portal-pipelines__card-drift">
{" · "}
{driftCount} drift{driftCount > 1 ? "s" : ""}
</span>
)}
</span>
</div>
</Card>
);
}
@@ -0,0 +1,25 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PipelineComposer } from "@portal/components/pipelines/PipelineComposer";
const meta: Meta<typeof PipelineComposer> = {
title: "Portal/Pipelines/PipelineComposer",
component: PipelineComposer,
parameters: { layout: "fullscreen" },
args: { open: true, onClose: () => console.log("close") },
decorators: [
(S) => (
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof PipelineComposer>;
/** Opens on the source step; step through Operations and Routing in the footer. */
export const Open: Story = {};
export const Closed: Story = {
args: { open: false },
};
@@ -0,0 +1,345 @@
import { useState } from "react";
import { Button, Chip, Modal } from "@shared/components";
import {
DESTINATION_OPTIONS,
PIPELINE_OPS,
PIPELINE_AGENTS,
SOURCE_OPTIONS,
type OpKind,
type PipelineOp,
} from "@shared/data/ops";
import {
OP_KIND_ACCENT,
STAGE_COLOR_VAR,
} from "@portal/components/pipelines/stageAccent";
const COMPOSER_STEPS = ["Source", "Operations", "Routing"] as const;
const OP_KIND_LABEL: Record<OpKind, string> = {
ingest: "Ingest",
validate: "Validate",
modify: "Modify",
secure: "Secure",
store: "Route / Store",
alert: "Alerts",
};
/** Selectable ops in the picker — excludes pipeline-only structural ops. */
const PICKER_OPS: Record<OpKind, PipelineOp[]> = (() => {
const out = {} as Record<OpKind, PipelineOp[]>;
for (const kind of Object.keys(PIPELINE_OPS) as OpKind[]) {
out[kind] = PIPELINE_OPS[kind].filter((op) => !op.pipelineOnly);
}
return out;
})();
function lookupPickerOp(id: string): PipelineOp | null {
for (const kind of Object.keys(PICKER_OPS) as OpKind[]) {
const found = PICKER_OPS[kind].find((op) => op.id === id);
if (found) return found;
}
return null;
}
export interface PipelineComposerProps {
open: boolean;
onClose: () => void;
}
/** Three-step wizard: pick a source, compose the op chain, route the output. */
export function PipelineComposer({ open, onClose }: PipelineComposerProps) {
const [step, setStep] = useState(0);
const [source, setSource] = useState<string>("upload");
const [selectedOps, setSelectedOps] = useState<string[]>([
"extract",
"validate",
"redact",
]);
const [destination, setDestination] = useState<string>("vault");
const [notifyEmail, setNotifyEmail] = useState(true);
const [notifyWebhook, setNotifyWebhook] = useState(false);
const [reviewQueue, setReviewQueue] = useState(true);
function reset() {
setStep(0);
setSource("upload");
setSelectedOps(["extract", "validate", "redact"]);
setDestination("vault");
setNotifyEmail(true);
setNotifyWebhook(false);
setReviewQueue(true);
}
function close() {
onClose();
// Defer reset so it doesn't flash mid-close-animation.
setTimeout(reset, 0);
}
function deploy() {
// TODO(backend): POST /v1/pipelines { source, ops: selectedOps, destination, alerts }
close();
}
function toggleOp(id: string) {
setSelectedOps((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
}
function applyAgent(ops: string[]) {
// A bundle lists its full op set, but structural rails (retention,
// residency, access policy) aren't user-chainable picker ops — add only
// ops that exist in the picker so every chain chip resolves to a label.
const pickable = ops.filter((id) => lookupPickerOp(id) !== null);
setSelectedOps((prev) => Array.from(new Set([...prev, ...pickable])));
}
const isLast = step === COMPOSER_STEPS.length - 1;
const canAdvance = step === 1 ? selectedOps.length > 0 : true;
return (
<Modal
open={open}
onClose={close}
width="xl"
title="New pipeline"
subtitle="Pick a source, compose the operation chain, then route the output."
footer={
<>
<div className="portal-pipelines__composer-steps" aria-hidden>
{COMPOSER_STEPS.map((label, i) => (
<span
key={label}
className={
"portal-pipelines__composer-step" +
(i === step ? " is-active" : i < step ? " is-done" : "")
}
>
{i + 1}. {label}
</span>
))}
</div>
<Button variant="ghost" onClick={close}>
Cancel
</Button>
{step > 0 && (
<Button variant="outline" onClick={() => setStep((s) => s - 1)}>
Back
</Button>
)}
{isLast ? (
<Button
variant="gradient"
onClick={deploy}
trailingIcon={<span aria-hidden></span>}
>
Deploy pipeline
</Button>
) : (
<Button
variant="gradient"
onClick={() => setStep((s) => s + 1)}
disabled={!canAdvance}
trailingIcon={<span aria-hidden></span>}
>
Continue
</Button>
)}
</>
}
>
<div className="portal-pipelines__composer">
{step === 0 && (
<div className="portal-pipelines__composer-body">
<div className="portal-pipelines__composer-grid">
<button
type="button"
className={
"portal-pipelines__option" +
(source === "any" ? " is-selected" : "")
}
onClick={() => setSource("any")}
>
<span className="portal-pipelines__option-label">
Any source
</span>
<span className="portal-pipelines__option-desc">
Accept documents from every connected channel
</span>
</button>
{SOURCE_OPTIONS.map((opt) => (
<button
key={opt.id}
type="button"
className={
"portal-pipelines__option" +
(source === opt.id ? " is-selected" : "")
}
onClick={() => setSource(opt.id)}
>
<span className="portal-pipelines__option-label">
{opt.label}
</span>
<span className="portal-pipelines__option-desc">
{opt.desc}
</span>
</button>
))}
</div>
</div>
)}
{step === 1 && (
<div className="portal-pipelines__composer-body">
<div className="portal-pipelines__chain">
<span className="portal-pipelines__chain-label">
Operation chain ({selectedOps.length})
</span>
<div className="portal-pipelines__chain-chips">
{selectedOps.length === 0 ? (
<span className="portal-pipelines__chain-empty">
Add operations from the library below.
</span>
) : (
selectedOps.map((id) => {
const op = lookupPickerOp(id);
const accent = op ? OP_KIND_ACCENT[op.kind] : "purple";
return (
<Chip
key={id}
tone={accent}
size="sm"
onRemove={() => toggleOp(id)}
>
{op?.label ?? id}
</Chip>
);
})
)}
</div>
</div>
<div className="portal-pipelines__agents">
<span className="portal-pipelines__agents-label">
Quick-add bundles
</span>
<div className="portal-pipelines__agents-row">
{PIPELINE_AGENTS.map((agent) => (
<Chip
key={agent.id}
tone="neutral"
size="sm"
onClick={() => applyAgent(agent.ops)}
>
+ {agent.label}
</Chip>
))}
</div>
</div>
<div className="portal-pipelines__library">
{(Object.keys(PICKER_OPS) as OpKind[]).map((kind) => (
<div key={kind} className="portal-pipelines__library-group">
<div className="portal-pipelines__library-head">
<span
className="portal-pipelines__library-pip"
style={{
background: STAGE_COLOR_VAR[OP_KIND_ACCENT[kind]],
}}
aria-hidden
/>
{OP_KIND_LABEL[kind]}
</div>
<div className="portal-pipelines__library-chips">
{PICKER_OPS[kind].map((op) => {
const on = selectedOps.includes(op.id);
return (
<Chip
key={op.id}
tone={on ? OP_KIND_ACCENT[kind] : "neutral"}
size="sm"
onClick={() => toggleOp(op.id)}
>
{on ? "✓ " : ""}
{op.label}
</Chip>
);
})}
</div>
</div>
))}
</div>
</div>
)}
{step === 2 && (
<div className="portal-pipelines__composer-body">
<span className="portal-pipelines__chain-label">Destination</span>
<div className="portal-pipelines__composer-grid">
{DESTINATION_OPTIONS.map((opt) => (
<button
key={opt.id}
type="button"
className={
"portal-pipelines__option" +
(destination === opt.id ? " is-selected" : "")
}
onClick={() => setDestination(opt.id)}
>
<span className="portal-pipelines__option-label">
{opt.label}
</span>
<span className="portal-pipelines__option-desc">
{opt.desc}
</span>
</button>
))}
</div>
<span className="portal-pipelines__chain-label">Alerts</span>
<div className="portal-pipelines__alerts">
<label className="portal-pipelines__alert">
<input
type="checkbox"
checked={notifyEmail}
onChange={(e) => setNotifyEmail(e.target.checked)}
/>
<span>
<strong>Email on failure</strong>
<span>
Notify the on-call list when error rate trips its bound
</span>
</span>
</label>
<label className="portal-pipelines__alert">
<input
type="checkbox"
checked={notifyWebhook}
onChange={(e) => setNotifyWebhook(e.target.checked)}
/>
<span>
<strong>Webhook on completion</strong>
<span>POST a run summary to a URL you control</span>
</span>
</label>
<label className="portal-pipelines__alert">
<input
type="checkbox"
checked={reviewQueue}
onChange={(e) => setReviewQueue(e.target.checked)}
/>
<span>
<strong>Route low-confidence to review</strong>
<span>
Send docs under the confidence bound to a human queue
</span>
</span>
</label>
</div>
</div>
)}
</div>
</Modal>
);
}
@@ -0,0 +1,31 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PipelineDetail } from "@portal/components/pipelines/PipelineDetail";
import {
DEGRADED_PIPELINE,
HEALTHY_PIPELINE,
} from "@portal/components/pipelines/storyFixtures";
const meta: Meta<typeof PipelineDetail> = {
title: "Portal/Pipelines/PipelineDetail",
component: PipelineDetail,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "32rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof PipelineDetail>;
/** Clean golden set, no drift. */
export const Healthy: Story = {
args: { pipeline: HEALTHY_PIPELINE },
};
/** Failing golden cases plus warning- and info-severity schema drift. */
export const DegradedWithDrift: Story = {
args: { pipeline: DEGRADED_PIPELINE },
};
@@ -0,0 +1,146 @@
import {
Chip,
EmptyState,
ProgressBar,
StatTile,
StatusBadge,
} from "@shared/components";
import type { Pipeline, SchemaDrift } from "@portal/api/pipelines";
import {
STAGE_ACCENT,
STAGE_COLOR_VAR,
} from "@portal/components/pipelines/stageAccent";
import { compact, pct } from "@portal/components/pipelines/format";
function DriftRow({ drift }: { drift: SchemaDrift }) {
return (
<li className="portal-pipelines__drift">
<span
className="portal-pipelines__drift-dot"
style={{
background:
drift.severity === "warning"
? "var(--color-amber)"
: "var(--color-blue)",
}}
aria-hidden
/>
<div className="portal-pipelines__drift-text">
<code className="portal-pipelines__drift-field">{drift.field}</code>
<span className="portal-pipelines__drift-note">{drift.note}</span>
</div>
<div className="portal-pipelines__drift-meta">
<span>
{drift.confidenceDelta > 0 ? "+" : ""}
{drift.confidenceDelta.toFixed(2)} conf
</span>
<span>{drift.affectedDocs} docs</span>
</div>
</li>
);
}
export interface PipelineDetailProps {
pipeline: Pipeline;
}
/** Drawer body: 24h metrics, the five stages, golden-set health, and schema drift. */
export function PipelineDetail({ pipeline }: PipelineDetailProps) {
const m = pipeline.metrics;
const goldenRatio = pipeline.golden.total
? pipeline.golden.passing / pipeline.golden.total
: 0;
const goldenClean = pipeline.golden.passing === pipeline.golden.total;
return (
<div className="portal-pipelines__detail">
<section className="portal-pipelines__detail-metrics">
<StatTile label="Docs / 24h" value={compact(m.docs24h)} />
<StatTile label="Throughput" value={`${m.throughputPerMin}/min`} />
<StatTile label="Error rate" value={pct(m.errorRate, 2)} />
<StatTile label="P95 latency" value={`${m.p95LatencyMs} ms`} />
<StatTile label="Uptime" value={pct(m.uptime, 2)} />
</section>
<section className="portal-pipelines__detail-section">
<h3 className="portal-pipelines__detail-h">Pipeline stages</h3>
<p className="portal-pipelines__detail-sub">
Every document flows through five stages between {pipeline.source} and{" "}
{pipeline.destination}.
</p>
<div className="portal-pipelines__stages">
{pipeline.stages.map((stage) => {
const accent = STAGE_ACCENT[stage.key];
return (
<div key={stage.key} className="portal-pipelines__stage">
<div className="portal-pipelines__stage-head">
<span
className="portal-pipelines__stage-pip"
style={{ background: STAGE_COLOR_VAR[accent] }}
aria-hidden
/>
<span className="portal-pipelines__stage-name">
{stage.label}
</span>
</div>
<div className="portal-pipelines__stage-chips">
{stage.ops.length === 0 ? (
<span className="portal-pipelines__stage-empty">
No ops
</span>
) : (
stage.ops.map((op) => (
<Chip key={op} tone={accent} size="sm">
{op}
</Chip>
))
)}
</div>
</div>
);
})}
</div>
</section>
<section className="portal-pipelines__detail-section">
<h3 className="portal-pipelines__detail-h">Golden-set validation</h3>
<div className="portal-pipelines__golden">
<div className="portal-pipelines__golden-head">
<StatusBadge tone={goldenClean ? "success" : "warning"} size="sm">
{pipeline.golden.passing} of {pipeline.golden.total} passing
</StatusBadge>
<span className="portal-pipelines__golden-when">
last run {pipeline.golden.lastRun}
</span>
</div>
<ProgressBar
value={goldenRatio}
color={
goldenClean
? "var(--color-green)"
: "linear-gradient(90deg, var(--color-amber), color-mix(in srgb, var(--color-amber) 70%, white))"
}
label={`Golden set ${pipeline.golden.passing} of ${pipeline.golden.total} passing`}
/>
</div>
</section>
<section className="portal-pipelines__detail-section">
<h3 className="portal-pipelines__detail-h">Schema drift</h3>
{pipeline.drift.length === 0 ? (
<EmptyState
size="compact"
title="No drift detected"
description="Every document in the last 24h matched its inferred schema."
/>
) : (
<ul className="portal-pipelines__drift-list">
{pipeline.drift.map((d) => (
<DriftRow key={d.field} drift={d} />
))}
</ul>
)}
</section>
</div>
);
}
@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PipelineListSkeleton } from "@portal/components/pipelines/PipelineListSkeleton";
const meta: Meta<typeof PipelineListSkeleton> = {
title: "Portal/Pipelines/PipelineListSkeleton",
component: PipelineListSkeleton,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "52rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof PipelineListSkeleton>;
export const Default: Story = {};
@@ -0,0 +1,19 @@
import { Card, Skeleton } from "@shared/components";
/** Placeholder fleet while the deployed pipelines load. */
export function PipelineListSkeleton() {
return (
<div className="portal-pipelines__list" aria-hidden>
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i} padding="loose" className="portal-pipelines__card">
<div className="portal-pipelines__card-head">
<Skeleton width="11rem" height="1.1rem" />
<Skeleton width="5rem" height="1.1rem" />
</div>
<Skeleton width="80%" height="0.75rem" />
<Skeleton height="3rem" />
</Card>
))}
</div>
);
}
@@ -0,0 +1,9 @@
/** Fraction → percentage string (0.004 → "0.40%"). */
export const pct = (n: number, digits = 1) => `${(n * 100).toFixed(digits)}%`;
/** Compact count for dense metric tiles (28941 → "28.9K"). */
export const compact = (n: number) =>
new Intl.NumberFormat(undefined, {
notation: "compact",
maximumFractionDigits: 1,
}).format(n);
@@ -0,0 +1,35 @@
import type { StageKey } from "@portal/api/pipelines";
import type { OpKind } from "@shared/data/ops";
/**
* Fixed accent per stage so the chip row reads the same across every pipeline.
* Each value is also a valid {@link import("@shared/components").ChipTone}, so
* it doubles as the chip tone wherever a stage colour is rendered.
*/
export type StageAccent = "green" | "blue" | "amber" | "red" | "purple";
export const STAGE_ACCENT: Record<StageKey, StageAccent> = {
ingest: "green",
validate: "blue",
modify: "amber",
secure: "red",
route: "purple",
};
/** Op-kind accent mirrors the stage palette; alerts share the route colour. */
export const OP_KIND_ACCENT: Record<OpKind, StageAccent> = {
ingest: "green",
validate: "blue",
modify: "amber",
secure: "red",
store: "purple",
alert: "purple",
};
export const STAGE_COLOR_VAR: Record<StageAccent, string> = {
green: "var(--color-green)",
blue: "var(--color-blue)",
amber: "var(--color-amber)",
red: "var(--color-red)",
purple: "var(--color-purple)",
};
@@ -0,0 +1,74 @@
import type { Pipeline } from "@portal/api/pipelines";
/** Sample pipelines shared by the Pipelines component stories. */
export const HEALTHY_PIPELINE: Pipeline = {
id: "pl-invoice-ap",
name: "Invoice → AP",
blurb: "Invoice extraction → three-way match → Postgres",
status: "healthy",
source: "S3 bucket watch",
destination: "Database",
version: "v2.8.0",
regions: ["us-east-1", "eu-west-1", "ap-southeast-1"],
metrics: {
docs24h: 53120,
throughputPerMin: 41,
errorRate: 0.006,
p95LatencyMs: 358,
uptime: 0.9997,
},
stages: [
{ key: "ingest", label: "Ingest", ops: ["Parse", "Classify", "Extract"] },
{
key: "validate",
label: "Validate",
ops: ["Schema validate", "Confidence bounds"],
},
{ key: "modify", label: "Modify", ops: ["PDF → CSV"] },
{
key: "secure",
label: "Secure",
ops: ["Redact PII", "Encryption at rest"],
},
{ key: "route", label: "Route / Store", ops: ["Primary store", "Notify"] },
],
golden: { passing: 42, total: 42, lastRun: "1h ago" },
drift: [],
};
export const DEGRADED_PIPELINE: Pipeline = {
...HEALTHY_PIPELINE,
id: "pl-prior-auth",
name: "Prior Auth Router",
blurb: "Prior-authorization intake → medical-necessity gate → payer webhook",
status: "degraded",
source: "Inbound webhook",
destination: "Outbound webhook",
version: "v3.1.0",
regions: ["us-east-1"],
metrics: {
docs24h: 11204,
throughputPerMin: 9,
errorRate: 0.031,
p95LatencyMs: 740,
uptime: 0.9962,
},
golden: { passing: 24, total: 28, lastRun: "47m ago" },
drift: [
{
field: "procedure_codes",
note: "New CPT modifier suffix not seen in prior examples",
confidenceDelta: -0.07,
severity: "warning",
affectedDocs: 18,
},
{
field: "payer",
note: "Two payers now emit a merged-entity name",
confidenceDelta: -0.03,
severity: "info",
affectedDocs: 6,
},
],
};
@@ -0,0 +1,44 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { AgentDetail } from "@portal/api/sources";
import { AgentPanel } from "@portal/components/sources/AgentPanel";
const meta: Meta<typeof AgentPanel> = {
title: "Portal/Sources/AgentPanel",
component: AgentPanel,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "48rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof AgentPanel>;
const healthy: AgentDetail = {
kind: "agent",
model: "claude-sonnet-4.5",
calls24h: 1342,
errorRate: 0.004,
confidence: 0.962,
escalations24h: 11,
assignedPipelines: ["Invoice v3", "AP Routing"],
scopes: ["documents:read", "pipelines:invoke", "extract:write"],
};
export const Healthy: Story = { args: { d: healthy } };
/** Error rate over the 5% alarm threshold flips the badge and bar to danger/amber. */
export const Degraded: Story = {
args: {
d: {
...healthy,
model: "claude-opus-4.1",
errorRate: 0.071,
confidence: 0.883,
escalations24h: 34,
},
},
};
@@ -0,0 +1,85 @@
import {
Button,
Chip,
ProgressBar,
StatTile,
StatusBadge,
} from "@shared/components";
import type { AgentDetail } from "@portal/api/sources";
import { pct } from "@portal/components/sources/format";
import "@portal/views/Sources.css";
export function AgentPanel({ d }: { d: AgentDetail }) {
const errorTone =
d.errorRate >= 0.05
? "danger"
: d.errorRate >= 0.02
? "warning"
: "success";
return (
<div className="portal-sources__detail">
<div className="portal-sources__stat-grid">
<StatTile label="Model" value={<code>{d.model}</code>} />
<StatTile label="Calls / 24h" value={d.calls24h.toLocaleString()} />
<StatTile
label="Error rate"
value={
<StatusBadge tone={errorTone} size="sm">
{pct(d.errorRate)}
</StatusBadge>
}
/>
<StatTile label="Escalations / 24h" value={d.escalations24h} />
</div>
<div className="portal-sources__bar-row">
<div className="portal-sources__bar-head">
<span>Mean confidence</span>
<strong>{pct(d.confidence)}</strong>
</div>
<ProgressBar
value={d.confidence}
color={
d.confidence >= 0.93 ? "var(--color-green)" : "var(--color-amber)"
}
label="Mean output confidence"
/>
</div>
<div className="portal-sources__detail-section">
<span className="portal-sources__detail-heading">
Assigned pipelines
</span>
<div className="portal-sources__chips">
{d.assignedPipelines.map((p) => (
<Chip key={p} tone="blue" size="sm">
{p}
</Chip>
))}
</div>
</div>
<div className="portal-sources__detail-section">
<span className="portal-sources__detail-heading">Scopes</span>
<div className="portal-sources__chips">
{d.scopes.map((s) => (
<Chip key={s} tone="neutral" size="sm">
{s}
</Chip>
))}
</div>
</div>
{/* TODO(backend): wire to GET /v1/sources/{id}/eval-runs and
POST /v1/sources/{id}/pause — currently inert demo controls. */}
<div className="portal-sources__detail-actions">
<Button size="sm" variant="outline">
View eval runs
</Button>
<Button size="sm" variant="ghost">
Pause agent
</Button>
</div>
</div>
);
}
@@ -0,0 +1,52 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ApiClientDetail } from "@portal/api/sources";
import { ApiClientPanel } from "@portal/components/sources/ApiClientPanel";
const meta: Meta<typeof ApiClientPanel> = {
title: "Portal/Sources/ApiClientPanel",
component: ApiClientPanel,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "48rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof ApiClientPanel>;
const active: ApiClientDetail = {
kind: "apiclient",
maskedKey: "sk_live_••••••••••••4f9a",
rateLimit: "600 req/min",
rateUsedPct: 0.42,
endpoints: [
{ method: "POST", path: "/v1/extract", calls24h: 1820 },
{ method: "POST", path: "/v1/redact", calls24h: 740 },
{ method: "GET", path: "/v1/documents/{id}", calls24h: 380 },
],
createdBy: "[email protected]",
lastRotated: "23 days ago",
};
export const Active: Story = { args: { d: active } };
/** Rate window near its ceiling pushes the thresholded bar into the warning band. */
export const NearLimit: Story = {
args: { d: { ...active, rateUsedPct: 0.93 } },
};
/** A revoked key: never rotated, no traffic, masked label flags the state. */
export const Revoked: Story = {
args: {
d: {
...active,
maskedKey: "sk_live_••••••••••••0000 (revoked)",
rateUsedPct: 0,
lastRotated: "never",
endpoints: active.endpoints.map((e) => ({ ...e, calls24h: 0 })),
},
},
};
@@ -0,0 +1,61 @@
import { Button, Chip, ProgressBar, StatTile } from "@shared/components";
import type { ApiClientDetail } from "@portal/api/sources";
import { pct } from "@portal/components/sources/format";
import "@portal/views/Sources.css";
export function ApiClientPanel({ d }: { d: ApiClientDetail }) {
return (
<div className="portal-sources__detail">
<div className="portal-sources__stat-grid">
<StatTile label="Secret key" value={<code>{d.maskedKey}</code>} />
<StatTile label="Rate limit" value={d.rateLimit} />
<StatTile label="Created by" value={d.createdBy} />
<StatTile label="Last rotated" value={d.lastRotated} />
</div>
<div className="portal-sources__bar-row">
<div className="portal-sources__bar-head">
<span>Rate-limit window</span>
<strong>{pct(d.rateUsedPct)} used</strong>
</div>
<ProgressBar
value={d.rateUsedPct}
thresholded
label="Rate-limit usage"
/>
</div>
<div className="portal-sources__detail-section">
<span className="portal-sources__detail-heading">Top endpoints</span>
<div className="portal-sources__endpoints">
{d.endpoints.map((e) => (
<div key={e.path} className="portal-sources__endpoint">
<Chip
tone={e.method === "GET" ? "green" : "blue"}
size="sm"
className="portal-sources__method"
>
{e.method}
</Chip>
<code className="portal-sources__endpoint-path">{e.path}</code>
<span className="portal-sources__endpoint-calls">
{e.calls24h.toLocaleString()} / 24h
</span>
</div>
))}
</div>
</div>
{/* TODO(backend): wire to POST /v1/sources/{id}/rotate-key and
DELETE /v1/sources/{id} — currently inert demo controls. */}
<div className="portal-sources__detail-actions">
<Button size="sm" variant="outline" accent="amber">
Rotate key
</Button>
<Button size="sm" variant="ghost" accent="red">
Revoke
</Button>
</div>
</div>
);
}
@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ConnectWizard } from "@portal/components/sources/ConnectWizard";
const meta: Meta<typeof ConnectWizard> = {
title: "Portal/Sources/ConnectWizard",
component: ConnectWizard,
parameters: { layout: "fullscreen" },
args: { open: true, onClose: () => {} },
};
export default meta;
type Story = StoryObj<typeof ConnectWizard>;
/** Opens on the type-picker step; Continue/Back walk through the three steps. */
export const Open: Story = {};
export const Closed: Story = { args: { open: false } };
@@ -0,0 +1,146 @@
import { useState } from "react";
import { Button, CodeBlock, Modal, StatTile } from "@shared/components";
import { type Source, SOURCE_TYPE_META } from "@portal/api/sources";
import "@portal/views/Sources.css";
const WIZARD_STEPS = ["Choose type", "Configure", "Review & connect"] as const;
const CONNECT_SNIPPET = `curl https://api.stirlingpdf.com/v1/extract \\
-H "Authorization: Bearer sk_live_••••" \\
-F "[email protected]" \\
-F "pipeline=invoice-v3"`;
interface ConnectWizardProps {
open: boolean;
onClose: () => void;
}
/**
* Guided shell for connecting a new source. The final step is a demo stub that
* closes without provisioning — wiring it to the backend creates the source.
*/
export function ConnectWizard({ open, onClose }: ConnectWizardProps) {
const [step, setStep] = useState(0);
const [type, setType] = useState<Source["type"]>("agent");
function close() {
onClose();
// Reset for the next open, after the close transition has finished.
setTimeout(() => {
setStep(0);
setType("agent");
}, 200);
}
const isLast = step === WIZARD_STEPS.length - 1;
function advance() {
if (isLast) {
// TODO(backend): POST /v1/sources { type, pipeline, region } — provision
// the source, then close on success.
close();
} else {
setStep((s) => s + 1);
}
}
return (
<Modal
open={open}
onClose={close}
width="lg"
title="Connect a source"
subtitle={`Step ${step + 1} of ${WIZARD_STEPS.length} · ${WIZARD_STEPS[step]}`}
footer={
<div className="portal-sources__wizard-footer">
<Button
variant="ghost"
size="sm"
onClick={() => (step === 0 ? close() : setStep((s) => s - 1))}
>
{step === 0 ? "Cancel" : "Back"}
</Button>
<Button
size="sm"
onClick={advance}
trailingIcon={!isLast ? <span aria-hidden></span> : undefined}
>
{isLast ? "Connect source" : "Continue"}
</Button>
</div>
}
>
<ol className="portal-sources__steps" aria-hidden>
{WIZARD_STEPS.map((label, i) => (
<li
key={label}
className={
"portal-sources__step" +
(i === step ? " is-active" : i < step ? " is-done" : "")
}
>
<span className="portal-sources__step-mark">
{i < step ? "✓" : i + 1}
</span>
{label}
</li>
))}
</ol>
{step === 0 && (
<div className="portal-sources__type-grid">
{(Object.keys(SOURCE_TYPE_META) as Source["type"][]).map((t) => {
const meta = SOURCE_TYPE_META[t];
return (
<button
key={t}
type="button"
className={
"portal-sources__type-card" +
(type === t ? " is-selected" : "")
}
onClick={() => setType(t)}
>
<span className="portal-sources__type-icon" aria-hidden>
{meta.icon}
</span>
<span className="portal-sources__type-name">{meta.label}</span>
</button>
);
})}
</div>
)}
{step === 1 && (
<div className="portal-sources__wizard-body">
<p className="portal-sources__wizard-lead">
Configure your <strong>{SOURCE_TYPE_META[type].label}</strong>.
Point it at Stirling and attach a default pipeline every document
this source ingests runs through it automatically.
</p>
<CodeBlock code={CONNECT_SNIPPET} caption="quickstart.sh" />
<p className="portal-sources__wizard-note">
Scopes, rate limits and IP allowlists can be tuned after the source
is connected.
</p>
</div>
)}
{step === 2 && (
<div className="portal-sources__wizard-body">
<p className="portal-sources__wizard-lead">
Ready to connect a new{" "}
<strong>{SOURCE_TYPE_META[type].label}</strong>. It starts paused so
you can verify the first few documents before going live.
</p>
<div className="portal-sources__stat-grid">
<StatTile label="Type" value={SOURCE_TYPE_META[type].label} />
<StatTile label="Default pipeline" value="Redact & Flatten" />
<StatTile label="Initial state" value="Paused" />
<StatTile label="Region" value="us-east-1" />
</div>
</div>
)}
</Modal>
);
}
@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { buildSourcesResponse } from "@portal/mocks/sources";
import { KpiStrip } from "@portal/components/sources/KpiStrip";
const meta: Meta<typeof KpiStrip> = {
title: "Portal/Sources/KpiStrip",
component: KpiStrip,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof KpiStrip>;
export const Pro: Story = {
args: { data: buildSourcesResponse("pro"), loading: false },
};
export const Enterprise: Story = {
args: { data: buildSourcesResponse("enterprise"), loading: false },
};
/** Loading collapses every card to the placeholder dash. */
export const Loading: Story = {
args: { data: null, loading: true },
};
/** Free tier reports zeros and a "connect a source" prompt. */
export const Free: Story = {
args: { data: buildSourcesResponse("free"), loading: false },
};
@@ -0,0 +1,39 @@
import { MetricCard, MetricStrip } from "@shared/components";
import type { SourcesResponse } from "@portal/api/sources";
/**
* KPI labels are product copy — they describe what each metric IS, not its
* current value. They stay client-side so the strip's structure is stable
* across loading / empty / ready states; only values + deltas flow from the API.
*/
const KPI_LABELS = [
"Agents active",
"Scenarios",
"Eval pass rate (7d)",
"Docs / 24h",
] as const;
interface KpiStripProps {
data: SourcesResponse | null;
loading: boolean;
}
export function KpiStrip({ data, loading }: KpiStripProps) {
return (
<MetricStrip>
{KPI_LABELS.map((label, i) => {
const k = loading ? undefined : data?.kpis[i];
return (
<MetricCard
key={label}
label={label}
value={k?.value ?? "—"}
delta={k?.delta}
deltaDirection={k?.deltaDirection}
description={k?.description}
/>
);
})}
</MetricStrip>
);
}
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { Source } from "@portal/api/sources";
import { sourcesFor } from "@portal/mocks/sources";
import { SourceDetailCard } from "@portal/components/sources/SourceDetailCard";
const ENTERPRISE = sourcesFor("enterprise");
const byType = (type: Source["type"]) =>
ENTERPRISE.find((s) => s.type === type)!;
const meta: Meta<typeof SourceDetailCard> = {
title: "Portal/Sources/SourceDetailCard",
component: SourceDetailCard,
parameters: { layout: "padded" },
args: { onClose: () => {} },
decorators: [
(S) => (
<div style={{ maxWidth: "56rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof SourceDetailCard>;
export const Agent: Story = { args: { source: byType("agent") } };
export const Webhook: Story = { args: { source: byType("webhook") } };
export const Connector: Story = { args: { source: byType("connector") } };
@@ -0,0 +1,40 @@
import { type Source, SOURCE_TYPE_META } from "@portal/api/sources";
import { SourceDetailPanel } from "@portal/components/sources/SourceDetailPanel";
import "@portal/views/Sources.css";
interface SourceDetailCardProps {
source: Source;
onClose: () => void;
}
/** Expanded type-specific detail for the selected table row. */
export function SourceDetailCard({ source, onClose }: SourceDetailCardProps) {
const meta = SOURCE_TYPE_META[source.type];
return (
<section className="portal-sources__expanded">
<header className="portal-sources__expanded-head">
<span
className={`portal-sources__type-dot portal-sources__type-dot--${meta.tone}`}
aria-hidden
>
{meta.icon}
</span>
<div>
<h2 className="portal-sources__expanded-title">{source.name}</h2>
<span className="portal-sources__expanded-sub">
{meta.label} · owned by {source.owner}
</span>
</div>
<button
type="button"
className="portal-sources__expanded-close"
onClick={onClose}
aria-label="Close detail"
>
×
</button>
</header>
<SourceDetailPanel source={source} />
</section>
);
}
@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { Source } from "@portal/api/sources";
import { sourcesFor } from "@portal/mocks/sources";
import { SourceDetailPanel } from "@portal/components/sources/SourceDetailPanel";
const ENTERPRISE = sourcesFor("enterprise");
const byType = (type: Source["type"]) =>
ENTERPRISE.find((s) => s.type === type)!;
const meta: Meta<typeof SourceDetailPanel> = {
title: "Portal/Sources/SourceDetailPanel",
component: SourceDetailPanel,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "48rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof SourceDetailPanel>;
export const Agent: Story = { args: { source: byType("agent") } };
export const ApiClient: Story = { args: { source: byType("apiclient") } };
export const Webhook: Story = { args: { source: byType("webhook") } };
/** "basic" covers editor, connector, email, desktop and batch sources. */
export const Basic: Story = { args: { source: byType("connector") } };
@@ -0,0 +1,34 @@
import { StatTile } from "@shared/components";
import type { BasicDetail, Source } from "@portal/api/sources";
import { AgentPanel } from "@portal/components/sources/AgentPanel";
import { ApiClientPanel } from "@portal/components/sources/ApiClientPanel";
import { WebhookPanel } from "@portal/components/sources/WebhookPanel";
import "@portal/views/Sources.css";
/** Generic key/value grid for the simpler source types (editor, connector, …). */
function BasicPanel({ rows }: { rows: BasicDetail["rows"] }) {
return (
<div className="portal-sources__detail">
<div className="portal-sources__stat-grid">
{rows.map((r) => (
<StatTile key={r.label} label={r.label} value={r.value} />
))}
</div>
</div>
);
}
/** Renders the detail payload for a source, dispatched on its discriminant. */
export function SourceDetailPanel({ source }: { source: Source }) {
const { detail } = source;
switch (detail.kind) {
case "agent":
return <AgentPanel d={detail} />;
case "apiclient":
return <ApiClientPanel d={detail} />;
case "webhook":
return <WebhookPanel d={detail} />;
case "basic":
return <BasicPanel rows={detail.rows} />;
}
}
@@ -0,0 +1,25 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { sourcesFor } from "@portal/mocks/sources";
import { SourcesTable } from "@portal/components/sources/SourcesTable";
const PRO = sourcesFor("pro");
const meta: Meta<typeof SourcesTable> = {
title: "Portal/Sources/SourcesTable",
component: SourcesTable,
parameters: { layout: "padded" },
args: { sources: PRO, expandedId: null, onRowClick: () => {} },
};
export default meta;
type Story = StoryObj<typeof SourcesTable>;
export const Default: Story = {};
/** A row with an open detail panel rotates its caret. */
export const RowExpanded: Story = {
args: { expandedId: PRO[1].id },
};
export const Enterprise: Story = {
args: { sources: sourcesFor("enterprise") },
};
@@ -0,0 +1,113 @@
import { useMemo } from "react";
import { Chip, StatusBadge, Table, type TableColumn } from "@shared/components";
import {
type Source,
SOURCE_STATUS_TONE,
SOURCE_TYPE_META,
} from "@portal/api/sources";
import "@portal/views/Sources.css";
interface SourcesTableProps {
sources: Source[];
/** Id of the row whose detail panel is open, drives the caret state. */
expandedId: string | null;
onRowClick: (source: Source) => void;
}
export function SourcesTable({
sources,
expandedId,
onRowClick,
}: SourcesTableProps) {
const columns = useMemo<TableColumn<Source>[]>(
() => [
{
key: "name",
header: "Source",
render: (s) => {
const meta = SOURCE_TYPE_META[s.type];
return (
<div className="portal-sources__name-cell">
<span
className={`portal-sources__type-dot portal-sources__type-dot--${meta.tone}`}
aria-hidden
>
{meta.icon}
</span>
<div className="portal-sources__name-text">
<strong>{s.name}</strong>
<Chip tone={meta.tone} size="sm">
{meta.label}
</Chip>
</div>
</div>
);
},
},
{
key: "status",
header: "Status",
render: (s) => (
<StatusBadge
tone={SOURCE_STATUS_TONE[s.status]}
size="sm"
pulse={s.status === "active"}
>
{s.status}
</StatusBadge>
),
},
{
key: "docs24h",
header: "Docs / 24h",
align: "right",
render: (s) => s.docs24h.toLocaleString(),
},
{
key: "docs30d",
header: "Docs / 30d",
align: "right",
render: (s) => s.docs30d.toLocaleString(),
},
{
key: "lastEvent",
header: "Last event",
render: (s) => (
<span className="portal-sources__muted">{s.lastEvent}</span>
),
},
{
key: "owner",
header: "Owner",
render: (s) => <span className="portal-sources__muted">{s.owner}</span>,
},
{
key: "expand",
header: "",
align: "right",
width: "2.5rem",
render: (s) => (
<span
className={
"portal-sources__caret" + (expandedId === s.id ? " is-open" : "")
}
aria-hidden
>
</span>
),
},
],
[expandedId],
);
return (
<Table<Source>
className="portal-sources__table"
columns={columns}
rows={sources}
rowKey={(s) => s.id}
onRowClick={onRowClick}
/>
);
}
@@ -0,0 +1,51 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { WebhookDetail } from "@portal/api/sources";
import { WebhookPanel } from "@portal/components/sources/WebhookPanel";
const meta: Meta<typeof WebhookPanel> = {
title: "Portal/Sources/WebhookPanel",
component: WebhookPanel,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "48rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof WebhookPanel>;
const healthy: WebhookDetail = {
kind: "webhook",
url: "https://hooks.acme.com/stirling/ingest",
authType: "HMAC-SHA256",
successRate: 0.991,
retries24h: 3,
recentDeliveries: [
{ event: "document.processed", status: 200, time: "9m ago" },
{ event: "pipeline.completed", status: 200, time: "22m ago" },
{ event: "document.processed", status: 503, time: "1h ago" },
],
};
export const Healthy: Story = { args: { d: healthy } };
/** Below the 95% floor: success badge goes danger, retries spike, 5xx deliveries. */
export const Failing: Story = {
args: {
d: {
...healthy,
url: "https://erp.acme.com/inbound/stirling",
authType: "Basic",
successRate: 0.72,
retries24h: 58,
recentDeliveries: [
{ event: "document.processed", status: 502, time: "2m ago" },
{ event: "document.processed", status: 502, time: "5m ago" },
{ event: "pipeline.completed", status: 200, time: "9m ago" },
],
},
},
};
@@ -0,0 +1,65 @@
import { Button, StatTile, StatusBadge } from "@shared/components";
import type { WebhookDetail } from "@portal/api/sources";
import { pct } from "@portal/components/sources/format";
import "@portal/views/Sources.css";
export function WebhookPanel({ d }: { d: WebhookDetail }) {
const rateTone =
d.successRate >= 0.99
? "success"
: d.successRate >= 0.95
? "warning"
: "danger";
return (
<div className="portal-sources__detail">
<div className="portal-sources__stat-grid">
<StatTile
label="Endpoint URL"
value={<code className="portal-sources__url">{d.url}</code>}
/>
<StatTile label="Auth type" value={d.authType} />
<StatTile
label="Success rate"
value={
<StatusBadge tone={rateTone} size="sm">
{pct(d.successRate)}
</StatusBadge>
}
/>
<StatTile label="Retries / 24h" value={d.retries24h} />
</div>
<div className="portal-sources__detail-section">
<span className="portal-sources__detail-heading">
Recent deliveries
</span>
<div className="portal-sources__endpoints">
{d.recentDeliveries.map((r, i) => (
<div key={i} className="portal-sources__endpoint">
<StatusBadge
tone={r.status < 300 ? "success" : "danger"}
size="sm"
showDot={false}
>
{r.status}
</StatusBadge>
<code className="portal-sources__endpoint-path">{r.event}</code>
<span className="portal-sources__endpoint-calls">{r.time}</span>
</div>
))}
</div>
</div>
{/* TODO(backend): wire to POST /v1/sources/{id}/test-event and
GET /v1/sources/{id}/signing-secret — currently inert demo controls. */}
<div className="portal-sources__detail-actions">
<Button size="sm" variant="outline">
Send test event
</Button>
<Button size="sm" variant="ghost">
View signing secret
</Button>
</div>
</div>
);
}
@@ -0,0 +1,4 @@
/** Format a 0..1 ratio as a one-decimal percentage, e.g. 0.962 → "96.2%". */
export function pct(n: number): string {
return `${(n * 100).toFixed(1)}%`;
}
@@ -0,0 +1,17 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AvailablePlans } from "@portal/components/usage/AvailablePlans";
import { PLAN_OPTIONS } from "@portal/mocks/usage";
const meta: Meta<typeof AvailablePlans> = {
title: "Portal/Usage/AvailablePlans",
component: AvailablePlans,
args: { plans: PLAN_OPTIONS, onSelect: () => {} },
};
export default meta;
type Story = StoryObj<typeof AvailablePlans>;
export const OnFree: Story = { args: { current: "free" } };
export const OnPro: Story = { args: { current: "pro" } };
export const OnEnterprise: Story = { args: { current: "enterprise" } };
@@ -0,0 +1,36 @@
import type { Tier } from "@portal/contexts/TierContext";
import type { PlanOption } from "@portal/api/usage";
import { PlanCard } from "@portal/components/usage/PlanCard";
import "@portal/views/Usage.css";
/** The plan-catalogue grid, marking the caller's current tier. */
export function AvailablePlans({
plans,
current,
onSelect,
}: {
plans: PlanOption[];
current: Tier;
onSelect: (plan: PlanOption) => void;
}) {
return (
<section className="portal-usage__plans-block">
<header className="portal-usage__section-head">
<h2 className="portal-usage__section-title">Plans</h2>
<p className="portal-usage__section-sub">
Move up or down at any time changes take effect next cycle.
</p>
</header>
<div className="portal-usage__plans-grid">
{plans.map((plan) => (
<PlanCard
key={plan.tier}
plan={plan}
isCurrent={plan.tier === current}
onSelect={() => onSelect(plan)}
/>
))}
</div>
</section>
);
}
@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import { BillingHistoryTable } from "@portal/components/usage/BillingHistoryTable";
import { buildBillingHistory } from "@portal/mocks/usage";
const meta: Meta<typeof BillingHistoryTable> = {
title: "Portal/Usage/BillingHistoryTable",
component: BillingHistoryTable,
};
export default meta;
type Story = StoryObj<typeof BillingHistoryTable>;
// Free: usage tally lines, no charges.
export const Free: Story = { globals: { tier: "free" } };
// Pro: platform fee + metered overage rows across cycles.
export const Pro: Story = { globals: { tier: "pro" } };
// Enterprise: committed draws plus a goodwill credit (negative amount).
export const Enterprise: Story = { globals: { tier: "enterprise" } };
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/billing/history", async () => {
await delay("infinite");
return HttpResponse.json(buildBillingHistory("pro"));
}),
],
},
},
};
export const Empty: Story = {
parameters: {
msw: {
handlers: [http.get("/v1/billing/history", () => HttpResponse.json([]))],
},
},
};
@@ -0,0 +1,136 @@
import {
Card,
EmptyState,
Skeleton,
StatusBadge,
Table,
type StatusTone,
type TableColumn,
} from "@shared/components";
import { useTier } from "@portal/contexts/TierContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import {
fetchBillingHistory,
type BillingHistoryRow,
type InvoiceStatus,
} from "@portal/api/usage";
import { USD, formatBillingDate } from "@portal/components/usage/format";
import "@portal/views/Usage.css";
const STATUS_TONE: Record<InvoiceStatus, StatusTone> = {
paid: "success",
due: "warning",
pending: "info",
refunded: "neutral",
};
const STATUS_LABEL: Record<InvoiceStatus, string> = {
paid: "Paid",
due: "Due",
pending: "Pending",
refunded: "Refunded",
};
/** Invoice / line-item history for the current and prior billing cycles. */
export function BillingHistoryTable() {
const { tier } = useTier();
const state = useAsync<BillingHistoryRow[]>(
() => fetchBillingHistory(tier),
[tier],
);
const { data: rows } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
const columns: TableColumn<BillingHistoryRow>[] = [
{
key: "date",
header: "Date",
render: (r) => (
<span className="portal-usage__hist-date">
{formatBillingDate(r.date)}
</span>
),
width: "9rem",
},
{
key: "description",
header: "Description",
render: (r) => r.description,
},
{
key: "docs",
header: "Docs",
align: "right",
render: (r) => (r.docs > 0 ? r.docs.toLocaleString() : "—"),
width: "8rem",
},
{
key: "amount",
header: "Amount",
align: "right",
render: (r) => (
<span
className={
r.amount < 0
? "portal-usage__hist-credit"
: "portal-usage__hist-amount"
}
>
{r.amount < 0
? `${USD.format(Math.abs(r.amount))}`
: USD.format(r.amount)}
</span>
),
width: "8rem",
},
{
key: "status",
header: "Status",
align: "right",
render: (r) => (
<StatusBadge tone={STATUS_TONE[r.status]} size="sm">
{STATUS_LABEL[r.status]}
</StatusBadge>
),
width: "8rem",
},
];
return (
<section className="portal-usage__hist-block">
<header className="portal-usage__section-head">
<h2 className="portal-usage__section-title">Billing history</h2>
<p className="portal-usage__section-sub">
Line items from the current and prior billing cycles.
</p>
</header>
{isLoading && (
<div className="portal-usage__hist-skeleton" aria-hidden>
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} height="2.5rem" />
))}
</div>
)}
{isEmpty && (
<EmptyState
size="compact"
title="No billing history"
description="Charges and credits appear here once your first cycle closes."
/>
)}
{rows && rows.length > 0 && (
<Card padding="none">
<Table
columns={columns}
rows={rows}
rowKey={(r) => r.id}
empty="No line items"
/>
</Card>
)}
</section>
);
}
@@ -0,0 +1,34 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { BillingKpiStrip } from "@portal/components/usage/BillingKpiStrip";
import { buildBillingSummary } from "@portal/mocks/usage";
const meta: Meta<typeof BillingKpiStrip> = {
title: "Portal/Usage/BillingKpiStrip",
component: BillingKpiStrip,
};
export default meta;
type Story = StoryObj<typeof BillingKpiStrip>;
// The "remaining in plan" KPI replaces overage on free.
export const Free: Story = {
args: { summary: buildBillingSummary("free") },
globals: { tier: "free" },
};
// Pro surfaces metered overage cost + docs past cap.
export const Pro: Story = {
args: { summary: buildBillingSummary("pro") },
globals: { tier: "pro" },
};
// Enterprise swaps overage for commit utilisation.
export const Enterprise: Story = {
args: { summary: buildBillingSummary("enterprise") },
globals: { tier: "enterprise" },
};
// Summary still loading — every metric falls back to an em dash.
export const Loading: Story = {
args: { summary: null },
globals: { tier: "pro" },
};
@@ -0,0 +1,76 @@
import { MetricCard, MetricStrip } from "@shared/components";
import { useTier } from "@portal/contexts/TierContext";
import { OVERAGE_RATE, type BillingSummary } from "@portal/api/usage";
import { USD, formatBillingDate } from "@portal/components/usage/format";
import "@portal/views/Usage.css";
/** Headline billing KPIs: docs processed, cost, the tier-relevant cap figure, and renewal. */
export function BillingKpiStrip({
summary,
}: {
summary: BillingSummary | null;
}) {
const { tier } = useTier();
// Overage is meaningless on free (gated) / enterprise (committed) — surface
// the more relevant headline figure for those tiers instead.
const overageCard =
tier === "free"
? {
label: "Remaining in plan",
value: summary
? `${(summary.includedDocs - summary.docsThisPeriod).toLocaleString()}`
: "—",
description: "docs before cap",
}
: tier === "enterprise"
? {
label: "Commit utilisation",
value: summary
? `${Math.round((summary.docsThisPeriod / summary.includedDocs) * 100)}%`
: "—",
description: "of committed volume",
}
: {
label: `Overage ($${OVERAGE_RATE.toFixed(2)}/doc)`,
value: summary ? USD.format(summary.overageCost) : "—",
description: summary
? `${summary.overageDocs.toLocaleString()} docs past cap`
: undefined,
};
return (
<MetricStrip>
<MetricCard
label="Docs this period"
value={summary ? summary.docsThisPeriod.toLocaleString() : "—"}
description={
summary
? `of ${summary.includedDocs.toLocaleString()} included`
: undefined
}
/>
<MetricCard
label="Cost this month"
value={summary ? USD.format(summary.costThisMonth) : "—"}
description={
summary && summary.monthlyFee > 0
? `incl. ${USD.format(summary.monthlyFee)} platform`
: tier === "free"
? "free plan"
: undefined
}
/>
<MetricCard
label={overageCard.label}
value={overageCard.value}
description={overageCard.description}
/>
<MetricCard
label="Next billing date"
value={summary ? formatBillingDate(summary.nextBillingDate) : "—"}
description={tier === "free" ? "resets monthly" : "auto-charge"}
/>
</MetricStrip>
);
}
@@ -0,0 +1,48 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CurrentPlanCard } from "@portal/components/usage/CurrentPlanCard";
import { buildBillingSummary } from "@portal/mocks/usage";
const meta: Meta<typeof CurrentPlanCard> = {
title: "Portal/Usage/CurrentPlanCard",
component: CurrentPlanCard,
args: { onUpgrade: () => {} },
decorators: [
(S) => (
<div style={{ maxWidth: "32rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof CurrentPlanCard>;
// Free, approaching the cap — warning banner + cap meter.
export const FreeApproachingCap: Story = {
args: { summary: buildBillingSummary("free") },
globals: { tier: "free" },
};
// Free, cap reached — danger banner, processing paused.
export const FreeCapReached: Story = {
args: {
summary: {
...buildBillingSummary("free"),
docsThisPeriod: 500,
capReached: true,
},
},
globals: { tier: "free" },
};
// Pro pay-as-you-go breakdown with metered overage.
export const Pro: Story = {
args: { summary: buildBillingSummary("pro") },
globals: { tier: "pro" },
};
// Enterprise committed-volume breakdown.
export const Enterprise: Story = {
args: { summary: buildBillingSummary("enterprise") },
globals: { tier: "enterprise" },
};
@@ -0,0 +1,173 @@
import {
Banner,
Button,
Card,
ProgressBar,
StatusBadge,
} from "@shared/components";
import { useTier } from "@portal/contexts/TierContext";
import { OVERAGE_RATE, type BillingSummary } from "@portal/api/usage";
import { USD } from "@portal/components/usage/format";
import "@portal/views/Usage.css";
function BreakdownRow({
label,
value,
emphasis,
}: {
label: string;
value: string;
emphasis?: boolean;
}) {
return (
<div
className={
"portal-usage__breakdown-row" +
(emphasis ? " portal-usage__breakdown-row--total" : "")
}
>
<span className="portal-usage__breakdown-label">{label}</span>
<span className="portal-usage__breakdown-value">{value}</span>
</div>
);
}
/**
* Current-plan summary card. The body adapts per tier: a cap meter + nudge on
* free, a metered pay-as-you-go breakdown on pro, a committed-volume breakdown
* on enterprise.
*/
export function CurrentPlanCard({
summary,
onUpgrade,
}: {
summary: BillingSummary;
onUpgrade: () => void;
}) {
const { tier } = useTier();
const usedRatio = summary.docsThisPeriod / summary.includedDocs;
return (
<Card padding="loose" className="portal-usage__plan-current">
<div className="portal-usage__plan-current-head">
<div>
<span className="portal-usage__plan-eyebrow">Current plan</span>
<h2 className="portal-usage__plan-name">{summary.planName}</h2>
</div>
<StatusBadge
tone={
tier === "enterprise"
? "purple"
: tier === "pro"
? "info"
: "neutral"
}
size="sm"
>
{tier === "free"
? "Free"
: tier === "pro"
? "Pay-as-you-go"
: "Committed"}
</StatusBadge>
</div>
{tier === "free" && (
<>
<div className="portal-usage__cap">
<div className="portal-usage__cap-row">
<span>
{summary.docsThisPeriod.toLocaleString()} /{" "}
{summary.includedDocs.toLocaleString()} docs
</span>
<span className="portal-usage__cap-pct">
{Math.round(usedRatio * 100)}%
</span>
</div>
<ProgressBar
value={usedRatio}
thresholded
height={8}
label="Free plan usage"
/>
</div>
{summary.capReached ? (
<Banner tone="danger" title="You've hit your free plan cap">
New documents are paused until next cycle. Upgrade to keep
processing without interruption.
</Banner>
) : (
<Banner tone="warning" title="Approaching your free plan cap">
You're at {Math.round(usedRatio * 100)}% of 500 docs/month.
Upgrade to pay-as-you-go to avoid a pause.
</Banner>
)}
</>
)}
{tier === "pro" && (
<div className="portal-usage__breakdown">
<BreakdownRow
label="Platform fee"
value={USD.format(summary.monthlyFee)}
/>
<BreakdownRow
label="Included docs"
value={`${summary.includedDocs.toLocaleString()}`}
/>
<BreakdownRow
label={`Overage · ${summary.overageDocs.toLocaleString()} docs @ $${OVERAGE_RATE.toFixed(2)}`}
value={USD.format(summary.overageCost)}
/>
<BreakdownRow
label="Projected this month"
value={USD.format(summary.costThisMonth)}
emphasis
/>
</div>
)}
{tier === "enterprise" && (
<div className="portal-usage__breakdown">
<BreakdownRow
label="Committed volume"
value={`${summary.includedDocs.toLocaleString()} docs/mo`}
/>
<BreakdownRow
label="Drawn this period"
value={`${summary.docsThisPeriod.toLocaleString()} docs`}
/>
<BreakdownRow
label="Effective rate"
value={`$${summary.overageRate.toFixed(3)} / doc`}
/>
<BreakdownRow
label="Monthly draw"
value={USD.format(summary.monthlyFee)}
emphasis
/>
</div>
)}
<div className="portal-usage__plan-actions">
{tier !== "enterprise" ? (
<Button
variant="gradient"
accent={tier === "free" ? "blue" : "purple"}
onClick={onUpgrade}
>
{tier === "free" ? "Upgrade plan" : "Talk to sales"}
</Button>
) : (
<Button variant="outline" accent="purple" onClick={onUpgrade}>
Adjust commitment
</Button>
)}
{/* TODO(backend): GET /v1/billing/invoices?format=pdf — bundle + download invoice PDFs. */}
<Button variant="ghost" size="md">
Download invoices
</Button>
</div>
</Card>
);
}
@@ -0,0 +1,31 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PlanCard } from "@portal/components/usage/PlanCard";
import { PLAN_OPTIONS } from "@portal/mocks/usage";
const [free, pro, enterprise] = PLAN_OPTIONS;
const meta: Meta<typeof PlanCard> = {
title: "Portal/Usage/PlanCard",
component: PlanCard,
args: { onSelect: () => {} },
decorators: [
(S) => (
<div style={{ maxWidth: "20rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof PlanCard>;
export const Free: Story = { args: { plan: free, isCurrent: false } };
export const Pro: Story = { args: { plan: pro, isCurrent: false } };
export const Enterprise: Story = {
args: { plan: enterprise, isCurrent: false },
};
// The active plan is outlined and its CTA disabled.
export const Current: Story = { args: { plan: pro, isCurrent: true } };
@@ -0,0 +1,66 @@
import { Button, Card, StatusBadge } from "@shared/components";
import type { PlanOption } from "@portal/api/usage";
import "@portal/views/Usage.css";
/** A single plan in the catalogue grid; highlighted when it's the active plan. */
export function PlanCard({
plan,
isCurrent,
onSelect,
}: {
plan: PlanOption;
isCurrent: boolean;
onSelect: () => void;
}) {
const accent = plan.tier === "enterprise" ? "purple" : "blue";
return (
<Card
accent={plan.tier === "free" ? undefined : accent}
padding="loose"
className={
"portal-usage__plan-card" +
(isCurrent ? " portal-usage__plan-card--current" : "")
}
>
<div className="portal-usage__plan-card-head">
<h3 className="portal-usage__plan-card-name">{plan.name}</h3>
{isCurrent && (
<StatusBadge tone="success" size="sm">
Current
</StatusBadge>
)}
</div>
<div className="portal-usage__plan-card-price">
<span className="portal-usage__plan-card-amount">{plan.price}</span>
<span className="portal-usage__plan-card-cadence">
{plan.priceCadence}
</span>
</div>
<p className="portal-usage__plan-card-blurb">{plan.blurb}</p>
<ul className="portal-usage__plan-card-features">
{plan.features.map((f) => (
<li key={f}>
<span aria-hidden className="portal-usage__plan-card-check">
</span>
{f}
</li>
))}
</ul>
<Button
variant={isCurrent ? "outline" : "gradient"}
accent={accent}
size="sm"
fullWidth
disabled={isCurrent}
onClick={onSelect}
>
{isCurrent
? "Your plan"
: plan.tier === "enterprise"
? "Contact sales"
: "Choose plan"}
</Button>
</Card>
);
}
@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { SpendCapControl } from "@portal/components/usage/SpendCapControl";
import { buildBillingSummary } from "@portal/mocks/usage";
const meta: Meta<typeof SpendCapControl> = {
title: "Portal/Usage/SpendCapControl",
component: SpendCapControl,
decorators: [
(S) => (
<div style={{ maxWidth: "28rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof SpendCapControl>;
// Free can't accrue spend — explanatory card, no slider.
export const Free: Story = {
args: { summary: buildBillingSummary("free") },
globals: { tier: "free" },
};
// Pro with a cap already set — interactive slider + projection meter.
export const ProCapEnabled: Story = {
args: { summary: buildBillingSummary("pro") },
globals: { tier: "pro" },
};
// Pro with no cap set — starts collapsed behind "Enable cap".
export const ProCapDisabled: Story = {
args: { summary: { ...buildBillingSummary("pro"), spendCap: null } },
globals: { tier: "pro" },
};
// Enterprise spend is contract-governed — read-only card.
export const Enterprise: Story = {
args: { summary: buildBillingSummary("enterprise") },
globals: { tier: "enterprise" },
};
@@ -0,0 +1,106 @@
import { useState } from "react";
import {
Button,
Card,
ProgressBar,
Slider,
StatusBadge,
} from "@shared/components";
import { useTier } from "@portal/contexts/TierContext";
import type { BillingSummary } from "@portal/api/usage";
import { USD } from "@portal/components/usage/format";
import "@portal/views/Usage.css";
/**
* Monthly spend-cap control. Only pay-as-you-go can accrue spend, so free and
* enterprise render explanatory cards instead of the interactive slider.
*/
export function SpendCapControl({ summary }: { summary: BillingSummary }) {
const { tier } = useTier();
const [enabled, setEnabled] = useState(summary.spendCap !== null);
const [cap, setCap] = useState(summary.spendCap ?? 1_000);
if (tier === "free") {
return (
<Card padding="loose" className="portal-usage__cap-card">
<h2 className="portal-usage__section-title">Spend cap</h2>
<p className="portal-usage__section-sub">
The free plan can't accrue spend your usage is hard-capped at 500
docs/month. Upgrade to pay-as-you-go to set a monthly spend cap.
</p>
</Card>
);
}
if (tier === "enterprise") {
return (
<Card padding="loose" className="portal-usage__cap-card">
<h2 className="portal-usage__section-title">Spend controls</h2>
<p className="portal-usage__section-sub">
Spend is governed by your committed-volume contract. Overage terms and
alert thresholds are managed with your account team.
</p>
<div className="portal-usage__cap-meta">
<StatusBadge tone="purple" size="sm">
Committed contract
</StatusBadge>
<span>Overage billed at ${summary.overageRate.toFixed(3)}/doc</span>
</div>
</Card>
);
}
const projected = summary.costThisMonth;
const capRatio = enabled ? Math.min(projected / cap, 1) : 0;
// TODO(backend): PUT /v1/billing/spend-cap { enabled, cap } — persist the cap
// so processing pauses server-side when projected spend reaches the limit.
return (
<Card padding="loose" className="portal-usage__cap-card">
<div className="portal-usage__cap-card-head">
<div>
<h2 className="portal-usage__section-title">Monthly spend cap</h2>
<p className="portal-usage__section-sub">
Pause processing automatically when spend reaches your limit.
</p>
</div>
<Button
variant={enabled ? "outline" : "gradient"}
size="sm"
onClick={() => setEnabled((v) => !v)}
>
{enabled ? "Disable cap" : "Enable cap"}
</Button>
</div>
{enabled && (
<>
<div className="portal-usage__cap-slider">
<Slider
value={cap}
min={500}
max={10_000}
step={250}
onChange={setCap}
formatValue={(v) => USD.format(v)}
/>
</div>
<div className="portal-usage__cap-row">
<span>
Projected {USD.format(projected)} of {USD.format(cap)} cap
</span>
<span className="portal-usage__cap-pct">
{Math.round(capRatio * 100)}%
</span>
</div>
<ProgressBar
value={capRatio}
thresholded
height={8}
label="Spend against cap"
/>
</>
)}
</Card>
);
}
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { UpgradeModal } from "@portal/components/usage/UpgradeModal";
import { PLAN_OPTIONS } from "@portal/mocks/usage";
const enterprisePlan =
PLAN_OPTIONS.find((p) => p.tier === "enterprise") ?? null;
const meta: Meta<typeof UpgradeModal> = {
title: "Portal/Usage/UpgradeModal",
component: UpgradeModal,
args: { open: true, onClose: () => {}, target: null },
};
export default meta;
type Story = StoryObj<typeof UpgradeModal>;
// Free user pushed to pay-as-you-go.
export const FromFree: Story = { args: { currentTier: "free" } };
// Pro user with no specific target — generic committed-pricing nudge.
export const FromPro: Story = { args: { currentTier: "pro" } };
// Pro user selecting the enterprise plan — sales-conversation copy.
export const ProToEnterprise: Story = {
args: { currentTier: "pro", target: enterprisePlan },
};
// Enterprise user routed to their account team.
export const FromEnterprise: Story = { args: { currentTier: "enterprise" } };

Some files were not shown because too many files have changed in this diff Show More