mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Portal: full mock-driven surfaces, demonolithed components, backend-ready mocks (#6686)
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 0–1 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
Reference in New Issue
Block a user