From 96accea9840f89644d00d6d54520ec0398fcdc9e Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:20:35 +0100 Subject: [PATCH] Portal: full mock-driven surfaces, demonolithed components, backend-ready mocks (#6686) --- frontend/portal/MOCKS.md | 76 ++ frontend/portal/UI_CONVENTIONS.md | 62 ++ frontend/portal/src/App.tsx | 8 + frontend/portal/src/ViewRouter.tsx | 65 +- frontend/portal/src/api/docs.ts | 27 + frontend/portal/src/api/infrastructure.ts | 70 ++ frontend/portal/src/api/pipelines.ts | 22 + frontend/portal/src/api/settings.ts | 16 + frontend/portal/src/api/sources.ts | 25 + frontend/portal/src/api/usage.ts | 44 ++ .../portal/src/components/SettingsModal.css | 257 +++++++ .../portal/src/components/SettingsModal.tsx | 471 +++++++++++++ frontend/portal/src/components/Sidebar.css | 34 +- frontend/portal/src/components/Sidebar.tsx | 94 ++- .../docs/AuthenticationSection.stories.tsx | 20 + .../components/docs/AuthenticationSection.tsx | 33 + .../docs/ComponentsSection.stories.tsx | 22 + .../src/components/docs/ComponentsSection.tsx | 43 ++ .../src/components/docs/DocsNav.stories.tsx | 41 ++ .../portal/src/components/docs/DocsNav.tsx | 72 ++ .../components/docs/DocsSection.stories.tsx | 32 + .../src/components/docs/DocsSection.tsx | 25 + .../docs/EndpointReferenceSection.stories.tsx | 20 + .../docs/EndpointReferenceSection.tsx | 72 ++ .../components/docs/ErrorsSection.stories.tsx | 22 + .../src/components/docs/ErrorsSection.tsx | 38 + .../docs/GettingStartedSection.stories.tsx | 24 + .../components/docs/GettingStartedSection.tsx | 66 ++ .../components/docs/LangSnippet.stories.tsx | 28 + .../src/components/docs/LangSnippet.tsx | 27 + .../docs/PlaybooksSection.stories.tsx | 22 + .../src/components/docs/PlaybooksSection.tsx | 42 ++ .../docs/RateLimitsSection.stories.tsx | 31 + .../src/components/docs/RateLimitsSection.tsx | 38 + .../components/docs/SdksSection.stories.tsx | 29 + .../src/components/docs/SdksSection.tsx | 44 ++ .../components/docs/SkillsSection.stories.tsx | 22 + .../src/components/docs/SkillsSection.tsx | 29 + .../docs/WebhooksSection.stories.tsx | 20 + .../src/components/docs/WebhooksSection.tsx | 33 + .../infrastructure/ApiKeyCard.stories.tsx | 68 ++ .../components/infrastructure/ApiKeyCard.tsx | 97 +++ .../infrastructure/ApiKeysTab.stories.tsx | 44 ++ .../components/infrastructure/ApiKeysTab.tsx | 61 ++ .../infrastructure/AuditTab.stories.tsx | 49 ++ .../components/infrastructure/AuditTab.tsx | 150 ++++ .../infrastructure/CreateKeyModal.stories.tsx | 16 + .../infrastructure/CreateKeyModal.tsx | 137 ++++ .../infrastructure/DeploymentsTab.stories.tsx | 46 ++ .../infrastructure/DeploymentsTab.tsx | 206 ++++++ .../infrastructure/SectionHeader.stories.tsx | 22 + .../infrastructure/SectionHeader.tsx | 9 + .../infrastructure/SecurityTab.stories.tsx | 46 ++ .../components/infrastructure/SecurityTab.tsx | 197 ++++++ .../infrastructure/StorageTab.stories.tsx | 80 +++ .../components/infrastructure/StorageTab.tsx | 186 +++++ .../infrastructure/TableSkeleton.stories.tsx | 23 + .../infrastructure/TableSkeleton.tsx | 16 + .../components/infrastructure/infraFormat.ts | 86 +++ .../pipelines/PipelineCard.stories.tsx | 30 + .../src/components/pipelines/PipelineCard.tsx | 109 +++ .../pipelines/PipelineComposer.stories.tsx | 25 + .../components/pipelines/PipelineComposer.tsx | 345 ++++++++++ .../pipelines/PipelineDetail.stories.tsx | 31 + .../components/pipelines/PipelineDetail.tsx | 146 ++++ .../PipelineListSkeleton.stories.tsx | 19 + .../pipelines/PipelineListSkeleton.tsx | 19 + .../portal/src/components/pipelines/format.ts | 9 + .../src/components/pipelines/stageAccent.ts | 35 + .../src/components/pipelines/storyFixtures.ts | 74 ++ .../components/sources/AgentPanel.stories.tsx | 44 ++ .../src/components/sources/AgentPanel.tsx | 85 +++ .../sources/ApiClientPanel.stories.tsx | 52 ++ .../src/components/sources/ApiClientPanel.tsx | 61 ++ .../sources/ConnectWizard.stories.tsx | 16 + .../src/components/sources/ConnectWizard.tsx | 146 ++++ .../components/sources/KpiStrip.stories.tsx | 29 + .../src/components/sources/KpiStrip.tsx | 39 ++ .../sources/SourceDetailCard.stories.tsx | 28 + .../components/sources/SourceDetailCard.tsx | 40 ++ .../sources/SourceDetailPanel.stories.tsx | 29 + .../components/sources/SourceDetailPanel.tsx | 34 + .../sources/SourcesTable.stories.tsx | 25 + .../src/components/sources/SourcesTable.tsx | 113 +++ .../sources/WebhookPanel.stories.tsx | 51 ++ .../src/components/sources/WebhookPanel.tsx | 65 ++ .../portal/src/components/sources/format.ts | 4 + .../usage/AvailablePlans.stories.tsx | 17 + .../src/components/usage/AvailablePlans.tsx | 36 + .../usage/BillingHistoryTable.stories.tsx | 41 ++ .../components/usage/BillingHistoryTable.tsx | 136 ++++ .../usage/BillingKpiStrip.stories.tsx | 34 + .../src/components/usage/BillingKpiStrip.tsx | 76 ++ .../usage/CurrentPlanCard.stories.tsx | 48 ++ .../src/components/usage/CurrentPlanCard.tsx | 173 +++++ .../src/components/usage/PlanCard.stories.tsx | 31 + .../portal/src/components/usage/PlanCard.tsx | 66 ++ .../usage/SpendCapControl.stories.tsx | 41 ++ .../src/components/usage/SpendCapControl.tsx | 106 +++ .../components/usage/UpgradeModal.stories.tsx | 28 + .../src/components/usage/UpgradeModal.tsx | 133 ++++ .../components/usage/UsageChart.stories.tsx | 46 ++ .../src/components/usage/UsageChart.tsx | 49 ++ .../portal/src/components/usage/format.ts | 15 + frontend/portal/src/contexts/UIContext.tsx | 12 +- frontend/portal/src/mocks/docs.ts | 414 +++++++++++ frontend/portal/src/mocks/handlers/docs.ts | 20 + frontend/portal/src/mocks/handlers/index.ts | 12 + .../src/mocks/handlers/infrastructure.ts | 46 ++ .../portal/src/mocks/handlers/pipelines.ts | 12 + .../portal/src/mocks/handlers/settings.ts | 12 + frontend/portal/src/mocks/handlers/sources.ts | 12 + frontend/portal/src/mocks/handlers/usage.ts | 35 + frontend/portal/src/mocks/infrastructure.ts | 648 ++++++++++++++++++ frontend/portal/src/mocks/pipelines.ts | 334 +++++++++ frontend/portal/src/mocks/settings.ts | 109 +++ frontend/portal/src/mocks/sources.ts | 545 +++++++++++++++ frontend/portal/src/mocks/usage.ts | 341 +++++++++ frontend/portal/src/views/DeveloperDocs.css | 525 ++++++++++++++ frontend/portal/src/views/DeveloperDocs.tsx | 95 +++ frontend/portal/src/views/Home.css | 13 +- frontend/portal/src/views/Home.tsx | 5 +- frontend/portal/src/views/Infrastructure.css | 428 ++++++++++++ frontend/portal/src/views/Infrastructure.tsx | 50 ++ frontend/portal/src/views/Pipelines.css | 518 ++++++++++++++ frontend/portal/src/views/Pipelines.tsx | 125 ++++ frontend/portal/src/views/Sources.css | 427 ++++++++++++ frontend/portal/src/views/Sources.tsx | 82 +++ frontend/portal/src/views/Usage.css | 340 +++++++++ frontend/portal/src/views/Usage.tsx | 105 +++ frontend/shared/assets/stirling-mark-dark.svg | 4 + .../shared/assets/stirling-mark-light.svg | 4 + .../shared/assets/stirling-pdf-logo-dark.svg | 4 + .../shared/assets/stirling-pdf-logo-light.svg | 4 + frontend/shared/components/Drawer.tsx | 150 ++-- frontend/shared/components/MetricStrip.css | 11 + .../shared/components/MetricStrip.stories.tsx | 24 + frontend/shared/components/MetricStrip.tsx | 24 + frontend/shared/components/Modal.tsx | 146 ++-- frontend/shared/components/StatTile.css | 39 ++ .../shared/components/StatTile.stories.tsx | 31 + frontend/shared/components/StatTile.tsx | 38 + frontend/shared/components/Table.css | 61 ++ frontend/shared/components/Table.stories.tsx | 109 +++ frontend/shared/components/Table.tsx | 106 +++ frontend/shared/components/index.ts | 3 + 146 files changed, 12221 insertions(+), 286 deletions(-) create mode 100644 frontend/portal/MOCKS.md create mode 100644 frontend/portal/UI_CONVENTIONS.md create mode 100644 frontend/portal/src/api/docs.ts create mode 100644 frontend/portal/src/api/infrastructure.ts create mode 100644 frontend/portal/src/api/pipelines.ts create mode 100644 frontend/portal/src/api/settings.ts create mode 100644 frontend/portal/src/api/sources.ts create mode 100644 frontend/portal/src/api/usage.ts create mode 100644 frontend/portal/src/components/SettingsModal.css create mode 100644 frontend/portal/src/components/SettingsModal.tsx create mode 100644 frontend/portal/src/components/docs/AuthenticationSection.stories.tsx create mode 100644 frontend/portal/src/components/docs/AuthenticationSection.tsx create mode 100644 frontend/portal/src/components/docs/ComponentsSection.stories.tsx create mode 100644 frontend/portal/src/components/docs/ComponentsSection.tsx create mode 100644 frontend/portal/src/components/docs/DocsNav.stories.tsx create mode 100644 frontend/portal/src/components/docs/DocsNav.tsx create mode 100644 frontend/portal/src/components/docs/DocsSection.stories.tsx create mode 100644 frontend/portal/src/components/docs/DocsSection.tsx create mode 100644 frontend/portal/src/components/docs/EndpointReferenceSection.stories.tsx create mode 100644 frontend/portal/src/components/docs/EndpointReferenceSection.tsx create mode 100644 frontend/portal/src/components/docs/ErrorsSection.stories.tsx create mode 100644 frontend/portal/src/components/docs/ErrorsSection.tsx create mode 100644 frontend/portal/src/components/docs/GettingStartedSection.stories.tsx create mode 100644 frontend/portal/src/components/docs/GettingStartedSection.tsx create mode 100644 frontend/portal/src/components/docs/LangSnippet.stories.tsx create mode 100644 frontend/portal/src/components/docs/LangSnippet.tsx create mode 100644 frontend/portal/src/components/docs/PlaybooksSection.stories.tsx create mode 100644 frontend/portal/src/components/docs/PlaybooksSection.tsx create mode 100644 frontend/portal/src/components/docs/RateLimitsSection.stories.tsx create mode 100644 frontend/portal/src/components/docs/RateLimitsSection.tsx create mode 100644 frontend/portal/src/components/docs/SdksSection.stories.tsx create mode 100644 frontend/portal/src/components/docs/SdksSection.tsx create mode 100644 frontend/portal/src/components/docs/SkillsSection.stories.tsx create mode 100644 frontend/portal/src/components/docs/SkillsSection.tsx create mode 100644 frontend/portal/src/components/docs/WebhooksSection.stories.tsx create mode 100644 frontend/portal/src/components/docs/WebhooksSection.tsx create mode 100644 frontend/portal/src/components/infrastructure/ApiKeyCard.stories.tsx create mode 100644 frontend/portal/src/components/infrastructure/ApiKeyCard.tsx create mode 100644 frontend/portal/src/components/infrastructure/ApiKeysTab.stories.tsx create mode 100644 frontend/portal/src/components/infrastructure/ApiKeysTab.tsx create mode 100644 frontend/portal/src/components/infrastructure/AuditTab.stories.tsx create mode 100644 frontend/portal/src/components/infrastructure/AuditTab.tsx create mode 100644 frontend/portal/src/components/infrastructure/CreateKeyModal.stories.tsx create mode 100644 frontend/portal/src/components/infrastructure/CreateKeyModal.tsx create mode 100644 frontend/portal/src/components/infrastructure/DeploymentsTab.stories.tsx create mode 100644 frontend/portal/src/components/infrastructure/DeploymentsTab.tsx create mode 100644 frontend/portal/src/components/infrastructure/SectionHeader.stories.tsx create mode 100644 frontend/portal/src/components/infrastructure/SectionHeader.tsx create mode 100644 frontend/portal/src/components/infrastructure/SecurityTab.stories.tsx create mode 100644 frontend/portal/src/components/infrastructure/SecurityTab.tsx create mode 100644 frontend/portal/src/components/infrastructure/StorageTab.stories.tsx create mode 100644 frontend/portal/src/components/infrastructure/StorageTab.tsx create mode 100644 frontend/portal/src/components/infrastructure/TableSkeleton.stories.tsx create mode 100644 frontend/portal/src/components/infrastructure/TableSkeleton.tsx create mode 100644 frontend/portal/src/components/infrastructure/infraFormat.ts create mode 100644 frontend/portal/src/components/pipelines/PipelineCard.stories.tsx create mode 100644 frontend/portal/src/components/pipelines/PipelineCard.tsx create mode 100644 frontend/portal/src/components/pipelines/PipelineComposer.stories.tsx create mode 100644 frontend/portal/src/components/pipelines/PipelineComposer.tsx create mode 100644 frontend/portal/src/components/pipelines/PipelineDetail.stories.tsx create mode 100644 frontend/portal/src/components/pipelines/PipelineDetail.tsx create mode 100644 frontend/portal/src/components/pipelines/PipelineListSkeleton.stories.tsx create mode 100644 frontend/portal/src/components/pipelines/PipelineListSkeleton.tsx create mode 100644 frontend/portal/src/components/pipelines/format.ts create mode 100644 frontend/portal/src/components/pipelines/stageAccent.ts create mode 100644 frontend/portal/src/components/pipelines/storyFixtures.ts create mode 100644 frontend/portal/src/components/sources/AgentPanel.stories.tsx create mode 100644 frontend/portal/src/components/sources/AgentPanel.tsx create mode 100644 frontend/portal/src/components/sources/ApiClientPanel.stories.tsx create mode 100644 frontend/portal/src/components/sources/ApiClientPanel.tsx create mode 100644 frontend/portal/src/components/sources/ConnectWizard.stories.tsx create mode 100644 frontend/portal/src/components/sources/ConnectWizard.tsx create mode 100644 frontend/portal/src/components/sources/KpiStrip.stories.tsx create mode 100644 frontend/portal/src/components/sources/KpiStrip.tsx create mode 100644 frontend/portal/src/components/sources/SourceDetailCard.stories.tsx create mode 100644 frontend/portal/src/components/sources/SourceDetailCard.tsx create mode 100644 frontend/portal/src/components/sources/SourceDetailPanel.stories.tsx create mode 100644 frontend/portal/src/components/sources/SourceDetailPanel.tsx create mode 100644 frontend/portal/src/components/sources/SourcesTable.stories.tsx create mode 100644 frontend/portal/src/components/sources/SourcesTable.tsx create mode 100644 frontend/portal/src/components/sources/WebhookPanel.stories.tsx create mode 100644 frontend/portal/src/components/sources/WebhookPanel.tsx create mode 100644 frontend/portal/src/components/sources/format.ts create mode 100644 frontend/portal/src/components/usage/AvailablePlans.stories.tsx create mode 100644 frontend/portal/src/components/usage/AvailablePlans.tsx create mode 100644 frontend/portal/src/components/usage/BillingHistoryTable.stories.tsx create mode 100644 frontend/portal/src/components/usage/BillingHistoryTable.tsx create mode 100644 frontend/portal/src/components/usage/BillingKpiStrip.stories.tsx create mode 100644 frontend/portal/src/components/usage/BillingKpiStrip.tsx create mode 100644 frontend/portal/src/components/usage/CurrentPlanCard.stories.tsx create mode 100644 frontend/portal/src/components/usage/CurrentPlanCard.tsx create mode 100644 frontend/portal/src/components/usage/PlanCard.stories.tsx create mode 100644 frontend/portal/src/components/usage/PlanCard.tsx create mode 100644 frontend/portal/src/components/usage/SpendCapControl.stories.tsx create mode 100644 frontend/portal/src/components/usage/SpendCapControl.tsx create mode 100644 frontend/portal/src/components/usage/UpgradeModal.stories.tsx create mode 100644 frontend/portal/src/components/usage/UpgradeModal.tsx create mode 100644 frontend/portal/src/components/usage/UsageChart.stories.tsx create mode 100644 frontend/portal/src/components/usage/UsageChart.tsx create mode 100644 frontend/portal/src/components/usage/format.ts create mode 100644 frontend/portal/src/mocks/docs.ts create mode 100644 frontend/portal/src/mocks/handlers/docs.ts create mode 100644 frontend/portal/src/mocks/handlers/infrastructure.ts create mode 100644 frontend/portal/src/mocks/handlers/pipelines.ts create mode 100644 frontend/portal/src/mocks/handlers/settings.ts create mode 100644 frontend/portal/src/mocks/handlers/sources.ts create mode 100644 frontend/portal/src/mocks/handlers/usage.ts create mode 100644 frontend/portal/src/mocks/infrastructure.ts create mode 100644 frontend/portal/src/mocks/pipelines.ts create mode 100644 frontend/portal/src/mocks/settings.ts create mode 100644 frontend/portal/src/mocks/sources.ts create mode 100644 frontend/portal/src/mocks/usage.ts create mode 100644 frontend/portal/src/views/DeveloperDocs.css create mode 100644 frontend/portal/src/views/DeveloperDocs.tsx create mode 100644 frontend/portal/src/views/Infrastructure.css create mode 100644 frontend/portal/src/views/Infrastructure.tsx create mode 100644 frontend/portal/src/views/Pipelines.css create mode 100644 frontend/portal/src/views/Pipelines.tsx create mode 100644 frontend/portal/src/views/Sources.css create mode 100644 frontend/portal/src/views/Sources.tsx create mode 100644 frontend/portal/src/views/Usage.css create mode 100644 frontend/portal/src/views/Usage.tsx create mode 100644 frontend/shared/assets/stirling-mark-dark.svg create mode 100644 frontend/shared/assets/stirling-mark-light.svg create mode 100644 frontend/shared/assets/stirling-pdf-logo-dark.svg create mode 100644 frontend/shared/assets/stirling-pdf-logo-light.svg create mode 100644 frontend/shared/components/MetricStrip.css create mode 100644 frontend/shared/components/MetricStrip.stories.tsx create mode 100644 frontend/shared/components/MetricStrip.tsx create mode 100644 frontend/shared/components/StatTile.css create mode 100644 frontend/shared/components/StatTile.stories.tsx create mode 100644 frontend/shared/components/StatTile.tsx create mode 100644 frontend/shared/components/Table.css create mode 100644 frontend/shared/components/Table.stories.tsx create mode 100644 frontend/shared/components/Table.tsx diff --git a/frontend/portal/MOCKS.md b/frontend/portal/MOCKS.md new file mode 100644 index 000000000..fd3d832f9 --- /dev/null +++ b/frontend/portal/MOCKS.md @@ -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/.ts ──► httpJson(fetch) ──► MSW handler (dev) ──► fixture builder + (the contract) └► real backend (prod) ─┘ +``` + +1. **`api/.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/.ts`** — MSW handlers that answer those endpoints + with `mocks/.ts` fixtures. Registered in `mocks/handlers/index.ts`. +3. **`mocks/.ts`** — fixture builders **and the canonical TS types** + (re-exported through `api/.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/.ts` (the exported types are the spec). +4. Delete `mocks/` once parity is confirmed. Optionally relocate the types from + `mocks/.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/.ts` JSDoc on +> each function is the authoritative per-endpoint reference. diff --git a/frontend/portal/UI_CONVENTIONS.md b/frontend/portal/UI_CONVENTIONS.md new file mode 100644 index 000000000..c4d51b581 --- /dev/null +++ b/frontend/portal/UI_CONVENTIONS.md @@ -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. diff --git a/frontend/portal/src/App.tsx b/frontend/portal/src/App.tsx index f51868e64..b01beade8 100644 --- a/frontend/portal/src/App.tsx +++ b/frontend/portal/src/App.tsx @@ -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 ; +} + export function App() { return ( @@ -66,6 +73,7 @@ export function App() { + diff --git a/frontend/portal/src/ViewRouter.tsx b/frontend/portal/src/ViewRouter.tsx index f8e0005e5..3051f7436 100644 --- a/frontend/portal/src/ViewRouter.tsx +++ b/frontend/portal/src/ViewRouter.tsx @@ -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> = { - 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 ( } /> - - } - /> - - } - /> - - } - /> + } /> + } /> } /> - - } - /> - } - /> - } - /> - - } - /> + } /> + } /> + } /> + {/* Settings is a modal overlay, not a route (see AppShell + UIContext). */} {/* Unknown paths land on Home. */} } /> diff --git a/frontend/portal/src/api/docs.ts b/frontend/portal/src/api/docs.ts new file mode 100644 index 000000000..cc4a62501 --- /dev/null +++ b/frontend/portal/src/api/docs.ts @@ -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 { + return httpJson("/v1/docs/nav"); +} + +/** GET /v1/docs/content — the tier-scaled reference content. */ +export async function fetchDocsContent(tier: Tier): Promise { + return httpJson(`/v1/docs/content?tier=${tier}`); +} diff --git a/frontend/portal/src/api/infrastructure.ts b/frontend/portal/src/api/infrastructure.ts new file mode 100644 index 000000000..0154ae6a4 --- /dev/null +++ b/frontend/portal/src/api/infrastructure.ts @@ -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 { + return httpJson( + `/v1/infrastructure/deployments${q(tier)}`, + ); +} + +/** GET /v1/infrastructure/api-keys?tier=… */ +export async function fetchApiKeys(tier: Tier): Promise { + return httpJson(`/v1/infrastructure/api-keys${q(tier)}`); +} + +/** GET /v1/infrastructure/security?tier=… */ +export async function fetchSecurity(tier: Tier): Promise { + return httpJson(`/v1/infrastructure/security${q(tier)}`); +} + +/** GET /v1/infrastructure/storage?tier=… */ +export async function fetchStorage(tier: Tier): Promise { + return httpJson(`/v1/infrastructure/storage${q(tier)}`); +} + +/** GET /v1/infrastructure/audit-log?tier=… */ +export async function fetchAuditLog(tier: Tier): Promise { + return httpJson(`/v1/infrastructure/audit-log${q(tier)}`); +} diff --git a/frontend/portal/src/api/pipelines.ts b/frontend/portal/src/api/pipelines.ts new file mode 100644 index 000000000..d3a250f31 --- /dev/null +++ b/frontend/portal/src/api/pipelines.ts @@ -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 { + return httpJson( + `/v1/pipelines?tier=${encodeURIComponent(tier)}`, + ); +} diff --git a/frontend/portal/src/api/settings.ts b/frontend/portal/src/api/settings.ts new file mode 100644 index 000000000..e6e850359 --- /dev/null +++ b/frontend/portal/src/api/settings.ts @@ -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 { + return httpJson( + `/v1/settings?tier=${encodeURIComponent(tier)}`, + ); +} diff --git a/frontend/portal/src/api/sources.ts b/frontend/portal/src/api/sources.ts new file mode 100644 index 000000000..cbc2c7cba --- /dev/null +++ b/frontend/portal/src/api/sources.ts @@ -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 { + return httpJson( + `/v1/sources?tier=${encodeURIComponent(tier)}`, + ); +} diff --git a/frontend/portal/src/api/usage.ts b/frontend/portal/src/api/usage.ts new file mode 100644 index 000000000..6e80d8312 --- /dev/null +++ b/frontend/portal/src/api/usage.ts @@ -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 { + return httpJson("/v1/billing/usage"); +} + +/** GET /v1/billing/summary?tier=… — KPI strip + current-plan figures. */ +export async function fetchBillingSummary(tier: Tier): Promise { + return httpJson( + `/v1/billing/summary?tier=${encodeURIComponent(tier)}`, + ); +} + +/** GET /v1/billing/plans — available plan catalogue. */ +export async function fetchPlanOptions(): Promise { + return httpJson("/v1/billing/plans"); +} + +/** GET /v1/billing/history?tier=… — invoice / line-item history. */ +export async function fetchBillingHistory( + tier: Tier, +): Promise { + return httpJson( + `/v1/billing/history?tier=${encodeURIComponent(tier)}`, + ); +} diff --git a/frontend/portal/src/components/SettingsModal.css b/frontend/portal/src/components/SettingsModal.css new file mode 100644 index 000000000..c43e0655a --- /dev/null +++ b/frontend/portal/src/components/SettingsModal.css @@ -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; + } +} diff --git a/frontend/portal/src/components/SettingsModal.tsx b/frontend/portal/src/components/SettingsModal.tsx new file mode 100644 index 000000000..3d3450d14 --- /dev/null +++ b/frontend/portal/src/components/SettingsModal.tsx @@ -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[] = [ + { 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("profile"); + + const { data: snapshot, loading } = useAsync( + () => 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>( + {}, + ); + + 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(() => { + 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 ( + + + Changes apply to this workspace. + + + + + } + > +
+ +
+ +
+ {tab === "profile" && ( + + )} + + {tab === "preferences" && ( + n.id) ?? []} + onToggle={(id, value) => + setNotifications((prev) => ({ ...prev, [id]: value })) + } + theme={theme} + onTheme={setTheme} + /> + )} + + {tab === "workspace" && ( + + )} +
+
+ ); +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* 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 ( +
+
+ +
+ + +
+
+ + +
+ ); + } + + return ( +
+
+ +
+
+ {name || "Account"} + {role && ( + + {role} + + )} +
+ {email} +
+ +
+ + + onName(e.target.value)} + placeholder="Your name" + /> + + + + onEmail(e.target.value)} + placeholder="you@company.com" + /> + +
+ ); +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Preferences */ +/* ──────────────────────────────────────────────────────────────────────── */ + +function PreferencesPanel({ + loading, + notifications, + order, + onToggle, + theme, + onTheme, +}: { + loading: boolean; + notifications: Record; + order: string[]; + onToggle: (id: string, value: boolean) => void; + theme: Theme; + onTheme: (theme: Theme) => void; +}) { + return ( +
+
+
+

Appearance

+

+ Choose how the portal looks on this device. +

+
+
+ {THEME_OPTIONS.map((opt) => ( + + ))} +
+
+ +
+
+

Notifications

+

+ Pick which events reach your inbox. +

+
+ + {loading && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+ + +
+ +
+ ))} +
+ )} + + {!loading && ( +
+ {order.map((id) => { + const copy = NOTIFICATION_COPY[id]; + if (!copy) return null; + return ( +
+
+ {copy.label} + {copy.description} +
+ onToggle(id, v)} + /> +
+ ); + })} +
+ )} +
+
+ ); +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* 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 ( +
+ + + +
+ ); + } + + return ( +
+ + onWorkspaceName(e.target.value)} + placeholder="Workspace name" + /> + + + + setName(e.target.value)} + placeholder="e.g. Production · ingest" + /> + + + +
+ {PERMISSION_OPTS.map((p) => ( + togglePerm(p)} + /> + ))} +
+
+ + + setIps(e.target.value)} + placeholder="52.14.0.0/16, 203.0.113.7/32" + /> + +
+ )} + + ); +} diff --git a/frontend/portal/src/components/infrastructure/DeploymentsTab.stories.tsx b/frontend/portal/src/components/infrastructure/DeploymentsTab.stories.tsx new file mode 100644 index 000000000..de559bd34 --- /dev/null +++ b/frontend/portal/src/components/infrastructure/DeploymentsTab.stories.tsx @@ -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 = { + title: "Portal/Infrastructure/DeploymentsTab", + component: DeploymentsTab, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +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: [] }), + ), + ], + }, + }, +}; diff --git a/frontend/portal/src/components/infrastructure/DeploymentsTab.tsx b/frontend/portal/src/components/infrastructure/DeploymentsTab.tsx new file mode 100644 index 000000000..49195016b --- /dev/null +++ b/frontend/portal/src/components/infrastructure/DeploymentsTab.tsx @@ -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[] = [ + { + key: "name", + header: "Region", + render: (r) => ( +
+ {r.name} + {r.code} +
+ ), + }, + { + key: "latency", + header: "Latency", + align: "right", + render: (r) => {r.latencyMs} ms, + }, + { + key: "load", + header: "Load", + width: "9rem", + render: (r) => ( +
+ + {pct(r.load)} +
+ ), + }, + { + key: "status", + header: "Status", + render: (r) => ( + + {titleCase(r.status)} + + ), + }, + { + key: "version", + header: "Version", + render: (r) => {r.version}, + }, + { + key: "uptime", + header: "Uptime", + align: "right", + render: (r) => ( + {pct(r.uptime, 3)} + ), + }, + { + key: "instances", + header: "Instances", + align: "right", + render: (r) => {r.instances}, + }, + { + key: "throughput", + header: "Throughput", + align: "right", + render: (r) => ( + + {r.throughput.toLocaleString()}/min + + ), + }, + { + key: "p99", + header: "P99", + align: "right", + render: (r) => {r.p99Ms} ms, + }, +]; + +const deployCols: TableColumn[] = [ + { + key: "version", + header: "Version", + render: (d) => {d.version}, + }, + { + key: "environment", + header: "Environment", + render: (d) => ( + + {d.environment} + + ), + }, + { key: "product", header: "Product", render: (d) => d.product }, + { + key: "status", + header: "Status", + render: (d) => ( + + {DEPLOY_LABEL[d.status]} + + ), + }, + { + key: "deployedBy", + header: "Deployed by", + render: (d) => {d.deployedBy}, + }, + { + key: "timestamp", + header: "When", + align: "right", + render: (d) => {d.timestamp}, + }, +]; + +export function DeploymentsTab() { + const { tier } = useTier(); + const state = useAsync( + () => fetchDeployments(tier), + [tier], + ); + const { data } = state; + const { isLoading, isEmpty } = useSectionFlags(state); + + return ( +
+
+ + + {isLoading && } + {isEmpty && ( + + )} + {!isEmpty && data && data.regions.length > 0 && ( + r.code} + /> + )} + + + +
+ + + {isLoading && } + {data && data.recent.length > 0 && ( +
d.id} + /> + )} + + + + ); +} diff --git a/frontend/portal/src/components/infrastructure/SectionHeader.stories.tsx b/frontend/portal/src/components/infrastructure/SectionHeader.stories.tsx new file mode 100644 index 000000000..b7f9a8301 --- /dev/null +++ b/frontend/portal/src/components/infrastructure/SectionHeader.stories.tsx @@ -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 = { + title: "Portal/Infrastructure/SectionHeader", + component: SectionHeader, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +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." }, +}; diff --git a/frontend/portal/src/components/infrastructure/SectionHeader.tsx b/frontend/portal/src/components/infrastructure/SectionHeader.tsx new file mode 100644 index 000000000..fa2ba6d0b --- /dev/null +++ b/frontend/portal/src/components/infrastructure/SectionHeader.tsx @@ -0,0 +1,9 @@ +/** Title + sub-line heading shared by every Infrastructure section. */ +export function SectionHeader({ title, sub }: { title: string; sub: string }) { + return ( +
+

{title}

+

{sub}

+
+ ); +} diff --git a/frontend/portal/src/components/infrastructure/SecurityTab.stories.tsx b/frontend/portal/src/components/infrastructure/SecurityTab.stories.tsx new file mode 100644 index 000000000..a0a732b95 --- /dev/null +++ b/frontend/portal/src/components/infrastructure/SecurityTab.stories.tsx @@ -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 = { + title: "Portal/Infrastructure/SecurityTab", + component: SecurityTab, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +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 }), + ), + ], + }, + }, +}; diff --git a/frontend/portal/src/components/infrastructure/SecurityTab.tsx b/frontend/portal/src/components/infrastructure/SecurityTab.tsx new file mode 100644 index 000000000..081e8f24e --- /dev/null +++ b/frontend/portal/src/components/infrastructure/SecurityTab.tsx @@ -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[] = [ + { + 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[] = [ + { 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[] = [ + { key: "label", header: "Label", render: (e) => e.label }, + { + key: "cidr", + header: "CIDR", + render: (e) => {e.cidr}, + }, + { + key: "addedBy", + header: "Added by", + render: (e) => {e.addedBy}, + }, + { + key: "added", + header: "Added", + align: "right", + render: (e) => {e.added}, + }, +]; + +export function SecurityTab() { + const { tier } = useTier(); + const state = useAsync(() => 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(null); + const [residency, setResidency] = useState(null); + + const accessValue = access ?? data?.accessPolicy ?? "stirling"; + const residencyValue = residency ?? data?.dataResidency ?? "us"; + + if (isLoading) { + return ( +
+ + +
+ ); + } + + if (isEmpty || !data) { + return ( + + ); + } + + return ( +
+
+ + + + {accessValue === "hyok" && ( + + )} + + + + + + +
+ +
+ +
+ {data.certs.map((c) => ( + +
+ {c.name} + + {CERT_LABEL[c.status]} + +
+

{c.detail}

+
+ ))} +
+
+ +
+ + {tier === "free" ? ( + + ) : ( + +
e.id} + empty="No IP ranges configured — all IPs allowed." + /> + + )} + + + ); +} diff --git a/frontend/portal/src/components/infrastructure/StorageTab.stories.tsx b/frontend/portal/src/components/infrastructure/StorageTab.stories.tsx new file mode 100644 index 000000000..57bcdbff7 --- /dev/null +++ b/frontend/portal/src/components/infrastructure/StorageTab.stories.tsx @@ -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 = { + title: "Portal/Infrastructure/StorageTab", + component: StorageTab, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +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); + }), + ], + }, + }, +}; diff --git a/frontend/portal/src/components/infrastructure/StorageTab.tsx b/frontend/portal/src/components/infrastructure/StorageTab.tsx new file mode 100644 index 000000000..348983ac6 --- /dev/null +++ b/frontend/portal/src/components/infrastructure/StorageTab.tsx @@ -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(() => fetchStorage(tier), [tier]); + const { data } = state; + const { isLoading, isEmpty } = useSectionFlags(state); + + // TODO(backend): PATCH /v1/infrastructure/storage { retention } + const [retention, setRetention] = useState(null); + const retentionValue = retention ?? data?.retention ?? "90"; + + if (isLoading) { + return ( +
+ + +
+ ); + } + + if (isEmpty || !data) { + return ( + + ); + } + + const usedFrac = data.quotaGb > 0 ? data.usedGb / data.quotaGb : 0; + const overThreshold = usedFrac > USAGE_DANGER_FRAC; + + return ( +
+
+ + +
+ + {data.usedGb.toLocaleString()} GB + + {" "} + / {data.quotaGb.toLocaleString()} GB + + + + {pct(usedFrac)} used + +
+ +
+
+ +
+ + +
    + {data.providers.map((p) => ( +
  • + + {PROVIDER_GLYPH[p.kind]} + + + {p.name} + {p.detail} + + {p.connected ? ( + + {p.usedGb} GB + + Connected + + + ) : ( + // TODO(backend): launch the provider OAuth/credential flow, + // then POST /v1/infrastructure/storage/providers/{id}/connect + + )} +
  • + ))} +
+
+ + + + + setNotifyEmail(e.target.checked)} + /> + + Email on failure + + Notify the on-call list when error rate trips its bound + + + + + +
+ + )} + + + ); +} diff --git a/frontend/portal/src/components/pipelines/PipelineDetail.stories.tsx b/frontend/portal/src/components/pipelines/PipelineDetail.stories.tsx new file mode 100644 index 000000000..a7c6c7763 --- /dev/null +++ b/frontend/portal/src/components/pipelines/PipelineDetail.stories.tsx @@ -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 = { + title: "Portal/Pipelines/PipelineDetail", + component: PipelineDetail, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** 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 }, +}; diff --git a/frontend/portal/src/components/pipelines/PipelineDetail.tsx b/frontend/portal/src/components/pipelines/PipelineDetail.tsx new file mode 100644 index 000000000..3336750ec --- /dev/null +++ b/frontend/portal/src/components/pipelines/PipelineDetail.tsx @@ -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 ( +
  • + +
    + {drift.field} + {drift.note} +
    +
    + + {drift.confidenceDelta > 0 ? "+" : ""} + {drift.confidenceDelta.toFixed(2)} conf + + {drift.affectedDocs} docs +
    +
  • + ); +} + +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 ( +
    +
    + + + + + +
    + +
    +

    Pipeline stages

    +

    + Every document flows through five stages between {pipeline.source} and{" "} + {pipeline.destination}. +

    +
    + {pipeline.stages.map((stage) => { + const accent = STAGE_ACCENT[stage.key]; + return ( +
    +
    + + + {stage.label} + +
    +
    + {stage.ops.length === 0 ? ( + + No ops + + ) : ( + stage.ops.map((op) => ( + + {op} + + )) + )} +
    +
    + ); + })} +
    +
    + +
    +

    Golden-set validation

    +
    +
    + + {pipeline.golden.passing} of {pipeline.golden.total} passing + + + last run {pipeline.golden.lastRun} + +
    + +
    +
    + +
    +

    Schema drift

    + {pipeline.drift.length === 0 ? ( + + ) : ( +
      + {pipeline.drift.map((d) => ( + + ))} +
    + )} +
    +
    + ); +} diff --git a/frontend/portal/src/components/pipelines/PipelineListSkeleton.stories.tsx b/frontend/portal/src/components/pipelines/PipelineListSkeleton.stories.tsx new file mode 100644 index 000000000..ae16b352f --- /dev/null +++ b/frontend/portal/src/components/pipelines/PipelineListSkeleton.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PipelineListSkeleton } from "@portal/components/pipelines/PipelineListSkeleton"; + +const meta: Meta = { + title: "Portal/Pipelines/PipelineListSkeleton", + component: PipelineListSkeleton, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
    + +
    + ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/portal/src/components/pipelines/PipelineListSkeleton.tsx b/frontend/portal/src/components/pipelines/PipelineListSkeleton.tsx new file mode 100644 index 000000000..8900ff699 --- /dev/null +++ b/frontend/portal/src/components/pipelines/PipelineListSkeleton.tsx @@ -0,0 +1,19 @@ +import { Card, Skeleton } from "@shared/components"; + +/** Placeholder fleet while the deployed pipelines load. */ +export function PipelineListSkeleton() { + return ( +
    + {Array.from({ length: 3 }).map((_, i) => ( + +
    + + +
    + + +
    + ))} +
    + ); +} diff --git a/frontend/portal/src/components/pipelines/format.ts b/frontend/portal/src/components/pipelines/format.ts new file mode 100644 index 000000000..ac38f33ec --- /dev/null +++ b/frontend/portal/src/components/pipelines/format.ts @@ -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); diff --git a/frontend/portal/src/components/pipelines/stageAccent.ts b/frontend/portal/src/components/pipelines/stageAccent.ts new file mode 100644 index 000000000..ee668a7af --- /dev/null +++ b/frontend/portal/src/components/pipelines/stageAccent.ts @@ -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 = { + 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 = { + ingest: "green", + validate: "blue", + modify: "amber", + secure: "red", + store: "purple", + alert: "purple", +}; + +export const STAGE_COLOR_VAR: Record = { + green: "var(--color-green)", + blue: "var(--color-blue)", + amber: "var(--color-amber)", + red: "var(--color-red)", + purple: "var(--color-purple)", +}; diff --git a/frontend/portal/src/components/pipelines/storyFixtures.ts b/frontend/portal/src/components/pipelines/storyFixtures.ts new file mode 100644 index 000000000..39458ce4d --- /dev/null +++ b/frontend/portal/src/components/pipelines/storyFixtures.ts @@ -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, + }, + ], +}; diff --git a/frontend/portal/src/components/sources/AgentPanel.stories.tsx b/frontend/portal/src/components/sources/AgentPanel.stories.tsx new file mode 100644 index 000000000..5289d0c62 --- /dev/null +++ b/frontend/portal/src/components/sources/AgentPanel.stories.tsx @@ -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 = { + title: "Portal/Sources/AgentPanel", + component: AgentPanel, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
    + +
    + ), + ], +}; +export default meta; +type Story = StoryObj; + +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, + }, + }, +}; diff --git a/frontend/portal/src/components/sources/AgentPanel.tsx b/frontend/portal/src/components/sources/AgentPanel.tsx new file mode 100644 index 000000000..3c3392788 --- /dev/null +++ b/frontend/portal/src/components/sources/AgentPanel.tsx @@ -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 ( +
    +
    + {d.model}} /> + + + {pct(d.errorRate)} + + } + /> + +
    + +
    +
    + Mean confidence + {pct(d.confidence)} +
    + = 0.93 ? "var(--color-green)" : "var(--color-amber)" + } + label="Mean output confidence" + /> +
    + +
    + + Assigned pipelines + +
    + {d.assignedPipelines.map((p) => ( + + {p} + + ))} +
    +
    + +
    + Scopes +
    + {d.scopes.map((s) => ( + + {s} + + ))} +
    +
    + + {/* TODO(backend): wire to GET /v1/sources/{id}/eval-runs and + POST /v1/sources/{id}/pause — currently inert demo controls. */} +
    + + +
    +
    + ); +} diff --git a/frontend/portal/src/components/sources/ApiClientPanel.stories.tsx b/frontend/portal/src/components/sources/ApiClientPanel.stories.tsx new file mode 100644 index 000000000..7c67e9078 --- /dev/null +++ b/frontend/portal/src/components/sources/ApiClientPanel.stories.tsx @@ -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 = { + title: "Portal/Sources/ApiClientPanel", + component: ApiClientPanel, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
    + +
    + ), + ], +}; +export default meta; +type Story = StoryObj; + +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: "you@acme.com", + 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 })), + }, + }, +}; diff --git a/frontend/portal/src/components/sources/ApiClientPanel.tsx b/frontend/portal/src/components/sources/ApiClientPanel.tsx new file mode 100644 index 000000000..2a2a52caa --- /dev/null +++ b/frontend/portal/src/components/sources/ApiClientPanel.tsx @@ -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 ( +
    +
    + {d.maskedKey}} /> + + + +
    + +
    +
    + Rate-limit window + {pct(d.rateUsedPct)} used +
    + +
    + +
    + Top endpoints +
    + {d.endpoints.map((e) => ( +
    + + {e.method} + + {e.path} + + {e.calls24h.toLocaleString()} / 24h + +
    + ))} +
    +
    + + {/* TODO(backend): wire to POST /v1/sources/{id}/rotate-key and + DELETE /v1/sources/{id} — currently inert demo controls. */} +
    + + +
    +
    + ); +} diff --git a/frontend/portal/src/components/sources/ConnectWizard.stories.tsx b/frontend/portal/src/components/sources/ConnectWizard.stories.tsx new file mode 100644 index 000000000..a78fb0ef1 --- /dev/null +++ b/frontend/portal/src/components/sources/ConnectWizard.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ConnectWizard } from "@portal/components/sources/ConnectWizard"; + +const meta: Meta = { + title: "Portal/Sources/ConnectWizard", + component: ConnectWizard, + parameters: { layout: "fullscreen" }, + args: { open: true, onClose: () => {} }, +}; +export default meta; +type Story = StoryObj; + +/** Opens on the type-picker step; Continue/Back walk through the three steps. */ +export const Open: Story = {}; + +export const Closed: Story = { args: { open: false } }; diff --git a/frontend/portal/src/components/sources/ConnectWizard.tsx b/frontend/portal/src/components/sources/ConnectWizard.tsx new file mode 100644 index 000000000..1c6471cc8 --- /dev/null +++ b/frontend/portal/src/components/sources/ConnectWizard.tsx @@ -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 "file=@invoice.pdf" \\ + -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("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 ( + + + + + } + > +
      + {WIZARD_STEPS.map((label, i) => ( +
    1. + + {i < step ? "✓" : i + 1} + + {label} +
    2. + ))} +
    + + {step === 0 && ( +
    + {(Object.keys(SOURCE_TYPE_META) as Source["type"][]).map((t) => { + const meta = SOURCE_TYPE_META[t]; + return ( + + ); + })} +
    + )} + + {step === 1 && ( +
    +

    + Configure your {SOURCE_TYPE_META[type].label}. + Point it at Stirling and attach a default pipeline — every document + this source ingests runs through it automatically. +

    + +

    + Scopes, rate limits and IP allowlists can be tuned after the source + is connected. +

    +
    + )} + + {step === 2 && ( +
    +

    + Ready to connect a new{" "} + {SOURCE_TYPE_META[type].label}. It starts paused so + you can verify the first few documents before going live. +

    +
    + + + + +
    +
    + )} +
    + ); +} diff --git a/frontend/portal/src/components/sources/KpiStrip.stories.tsx b/frontend/portal/src/components/sources/KpiStrip.stories.tsx new file mode 100644 index 000000000..36c290b50 --- /dev/null +++ b/frontend/portal/src/components/sources/KpiStrip.stories.tsx @@ -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 = { + title: "Portal/Sources/KpiStrip", + component: KpiStrip, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +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 }, +}; diff --git a/frontend/portal/src/components/sources/KpiStrip.tsx b/frontend/portal/src/components/sources/KpiStrip.tsx new file mode 100644 index 000000000..de319d05a --- /dev/null +++ b/frontend/portal/src/components/sources/KpiStrip.tsx @@ -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 ( + + {KPI_LABELS.map((label, i) => { + const k = loading ? undefined : data?.kpis[i]; + return ( + + ); + })} + + ); +} diff --git a/frontend/portal/src/components/sources/SourceDetailCard.stories.tsx b/frontend/portal/src/components/sources/SourceDetailCard.stories.tsx new file mode 100644 index 000000000..83f5171ec --- /dev/null +++ b/frontend/portal/src/components/sources/SourceDetailCard.stories.tsx @@ -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 = { + title: "Portal/Sources/SourceDetailCard", + component: SourceDetailCard, + parameters: { layout: "padded" }, + args: { onClose: () => {} }, + decorators: [ + (S) => ( +
    + +
    + ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Agent: Story = { args: { source: byType("agent") } }; +export const Webhook: Story = { args: { source: byType("webhook") } }; +export const Connector: Story = { args: { source: byType("connector") } }; diff --git a/frontend/portal/src/components/sources/SourceDetailCard.tsx b/frontend/portal/src/components/sources/SourceDetailCard.tsx new file mode 100644 index 000000000..d7f8b70e9 --- /dev/null +++ b/frontend/portal/src/components/sources/SourceDetailCard.tsx @@ -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 ( +
    +
    + + {meta.icon} + +
    +

    {source.name}

    + + {meta.label} · owned by {source.owner} + +
    + +
    + +
    + ); +} diff --git a/frontend/portal/src/components/sources/SourceDetailPanel.stories.tsx b/frontend/portal/src/components/sources/SourceDetailPanel.stories.tsx new file mode 100644 index 000000000..2581771ea --- /dev/null +++ b/frontend/portal/src/components/sources/SourceDetailPanel.stories.tsx @@ -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 = { + title: "Portal/Sources/SourceDetailPanel", + component: SourceDetailPanel, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
    + +
    + ), + ], +}; +export default meta; +type Story = StoryObj; + +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") } }; diff --git a/frontend/portal/src/components/sources/SourceDetailPanel.tsx b/frontend/portal/src/components/sources/SourceDetailPanel.tsx new file mode 100644 index 000000000..1938eb609 --- /dev/null +++ b/frontend/portal/src/components/sources/SourceDetailPanel.tsx @@ -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 ( +
    +
    + {rows.map((r) => ( + + ))} +
    +
    + ); +} + +/** 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 ; + case "apiclient": + return ; + case "webhook": + return ; + case "basic": + return ; + } +} diff --git a/frontend/portal/src/components/sources/SourcesTable.stories.tsx b/frontend/portal/src/components/sources/SourcesTable.stories.tsx new file mode 100644 index 000000000..7d7ea6fdc --- /dev/null +++ b/frontend/portal/src/components/sources/SourcesTable.stories.tsx @@ -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 = { + title: "Portal/Sources/SourcesTable", + component: SourcesTable, + parameters: { layout: "padded" }, + args: { sources: PRO, expandedId: null, onRowClick: () => {} }, +}; +export default meta; +type Story = StoryObj; + +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") }, +}; diff --git a/frontend/portal/src/components/sources/SourcesTable.tsx b/frontend/portal/src/components/sources/SourcesTable.tsx new file mode 100644 index 000000000..97eb687d1 --- /dev/null +++ b/frontend/portal/src/components/sources/SourcesTable.tsx @@ -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[]>( + () => [ + { + key: "name", + header: "Source", + render: (s) => { + const meta = SOURCE_TYPE_META[s.type]; + return ( +
    + + {meta.icon} + +
    + {s.name} + + {meta.label} + +
    +
    + ); + }, + }, + { + key: "status", + header: "Status", + render: (s) => ( + + {s.status} + + ), + }, + { + 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) => ( + {s.lastEvent} + ), + }, + { + key: "owner", + header: "Owner", + render: (s) => {s.owner}, + }, + { + key: "expand", + header: "", + align: "right", + width: "2.5rem", + render: (s) => ( + + ▸ + + ), + }, + ], + [expandedId], + ); + + return ( + + className="portal-sources__table" + columns={columns} + rows={sources} + rowKey={(s) => s.id} + onRowClick={onRowClick} + /> + ); +} diff --git a/frontend/portal/src/components/sources/WebhookPanel.stories.tsx b/frontend/portal/src/components/sources/WebhookPanel.stories.tsx new file mode 100644 index 000000000..673fc08a9 --- /dev/null +++ b/frontend/portal/src/components/sources/WebhookPanel.stories.tsx @@ -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 = { + title: "Portal/Sources/WebhookPanel", + component: WebhookPanel, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
    + +
    + ), + ], +}; +export default meta; +type Story = StoryObj; + +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" }, + ], + }, + }, +}; diff --git a/frontend/portal/src/components/sources/WebhookPanel.tsx b/frontend/portal/src/components/sources/WebhookPanel.tsx new file mode 100644 index 000000000..7dc6e0987 --- /dev/null +++ b/frontend/portal/src/components/sources/WebhookPanel.tsx @@ -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 ( +
    +
    + {d.url}} + /> + + + {pct(d.successRate)} + + } + /> + +
    + +
    + + Recent deliveries + +
    + {d.recentDeliveries.map((r, i) => ( +
    + + {r.status} + + {r.event} + {r.time} +
    + ))} +
    +
    + + {/* TODO(backend): wire to POST /v1/sources/{id}/test-event and + GET /v1/sources/{id}/signing-secret — currently inert demo controls. */} +
    + + +
    +
    + ); +} diff --git a/frontend/portal/src/components/sources/format.ts b/frontend/portal/src/components/sources/format.ts new file mode 100644 index 000000000..5ddbaaaaf --- /dev/null +++ b/frontend/portal/src/components/sources/format.ts @@ -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)}%`; +} diff --git a/frontend/portal/src/components/usage/AvailablePlans.stories.tsx b/frontend/portal/src/components/usage/AvailablePlans.stories.tsx new file mode 100644 index 000000000..4b172d0ad --- /dev/null +++ b/frontend/portal/src/components/usage/AvailablePlans.stories.tsx @@ -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 = { + title: "Portal/Usage/AvailablePlans", + component: AvailablePlans, + args: { plans: PLAN_OPTIONS, onSelect: () => {} }, +}; +export default meta; +type Story = StoryObj; + +export const OnFree: Story = { args: { current: "free" } }; + +export const OnPro: Story = { args: { current: "pro" } }; + +export const OnEnterprise: Story = { args: { current: "enterprise" } }; diff --git a/frontend/portal/src/components/usage/AvailablePlans.tsx b/frontend/portal/src/components/usage/AvailablePlans.tsx new file mode 100644 index 000000000..adc8c18ad --- /dev/null +++ b/frontend/portal/src/components/usage/AvailablePlans.tsx @@ -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 ( +
    +
    +

    Plans

    +

    + Move up or down at any time — changes take effect next cycle. +

    +
    +
    + {plans.map((plan) => ( + onSelect(plan)} + /> + ))} +
    +
    + ); +} diff --git a/frontend/portal/src/components/usage/BillingHistoryTable.stories.tsx b/frontend/portal/src/components/usage/BillingHistoryTable.stories.tsx new file mode 100644 index 000000000..c5144d62e --- /dev/null +++ b/frontend/portal/src/components/usage/BillingHistoryTable.stories.tsx @@ -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 = { + title: "Portal/Usage/BillingHistoryTable", + component: BillingHistoryTable, +}; +export default meta; +type Story = StoryObj; + +// 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([]))], + }, + }, +}; diff --git a/frontend/portal/src/components/usage/BillingHistoryTable.tsx b/frontend/portal/src/components/usage/BillingHistoryTable.tsx new file mode 100644 index 000000000..765fe7b54 --- /dev/null +++ b/frontend/portal/src/components/usage/BillingHistoryTable.tsx @@ -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 = { + paid: "success", + due: "warning", + pending: "info", + refunded: "neutral", +}; + +const STATUS_LABEL: Record = { + 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( + () => fetchBillingHistory(tier), + [tier], + ); + const { data: rows } = state; + const { isLoading, isEmpty } = useSectionFlags(state); + + const columns: TableColumn[] = [ + { + key: "date", + header: "Date", + render: (r) => ( + + {formatBillingDate(r.date)} + + ), + 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) => ( + + {r.amount < 0 + ? `−${USD.format(Math.abs(r.amount))}` + : USD.format(r.amount)} + + ), + width: "8rem", + }, + { + key: "status", + header: "Status", + align: "right", + render: (r) => ( + + {STATUS_LABEL[r.status]} + + ), + width: "8rem", + }, + ]; + + return ( +
    +
    +

    Billing history

    +

    + Line items from the current and prior billing cycles. +

    +
    + + {isLoading && ( +
    + {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
    + )} + + {isEmpty && ( + + )} + + {rows && rows.length > 0 && ( + +
    r.id} + empty="No line items" + /> + + )} + + ); +} diff --git a/frontend/portal/src/components/usage/BillingKpiStrip.stories.tsx b/frontend/portal/src/components/usage/BillingKpiStrip.stories.tsx new file mode 100644 index 000000000..84f50d95a --- /dev/null +++ b/frontend/portal/src/components/usage/BillingKpiStrip.stories.tsx @@ -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 = { + title: "Portal/Usage/BillingKpiStrip", + component: BillingKpiStrip, +}; +export default meta; +type Story = StoryObj; + +// 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" }, +}; diff --git a/frontend/portal/src/components/usage/BillingKpiStrip.tsx b/frontend/portal/src/components/usage/BillingKpiStrip.tsx new file mode 100644 index 000000000..58dd7258e --- /dev/null +++ b/frontend/portal/src/components/usage/BillingKpiStrip.tsx @@ -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 ( + + + 0 + ? `incl. ${USD.format(summary.monthlyFee)} platform` + : tier === "free" + ? "free plan" + : undefined + } + /> + + + + ); +} diff --git a/frontend/portal/src/components/usage/CurrentPlanCard.stories.tsx b/frontend/portal/src/components/usage/CurrentPlanCard.stories.tsx new file mode 100644 index 000000000..1c7522891 --- /dev/null +++ b/frontend/portal/src/components/usage/CurrentPlanCard.stories.tsx @@ -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 = { + title: "Portal/Usage/CurrentPlanCard", + component: CurrentPlanCard, + args: { onUpgrade: () => {} }, + decorators: [ + (S) => ( +
    + +
    + ), + ], +}; +export default meta; +type Story = StoryObj; + +// 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" }, +}; diff --git a/frontend/portal/src/components/usage/CurrentPlanCard.tsx b/frontend/portal/src/components/usage/CurrentPlanCard.tsx new file mode 100644 index 000000000..23a3fa72e --- /dev/null +++ b/frontend/portal/src/components/usage/CurrentPlanCard.tsx @@ -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 ( +
    + {label} + {value} +
    + ); +} + +/** + * 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 ( + +
    +
    + Current plan +

    {summary.planName}

    +
    + + {tier === "free" + ? "Free" + : tier === "pro" + ? "Pay-as-you-go" + : "Committed"} + +
    + + {tier === "free" && ( + <> +
    +
    + + {summary.docsThisPeriod.toLocaleString()} /{" "} + {summary.includedDocs.toLocaleString()} docs + + + {Math.round(usedRatio * 100)}% + +
    + +
    + {summary.capReached ? ( + + New documents are paused until next cycle. Upgrade to keep + processing without interruption. + + ) : ( + + You're at {Math.round(usedRatio * 100)}% of 500 docs/month. + Upgrade to pay-as-you-go to avoid a pause. + + )} + + )} + + {tier === "pro" && ( +
    + + + + +
    + )} + + {tier === "enterprise" && ( +
    + + + + +
    + )} + +
    + {tier !== "enterprise" ? ( + + ) : ( + + )} + {/* TODO(backend): GET /v1/billing/invoices?format=pdf — bundle + download invoice PDFs. */} + +
    +
    + ); +} diff --git a/frontend/portal/src/components/usage/PlanCard.stories.tsx b/frontend/portal/src/components/usage/PlanCard.stories.tsx new file mode 100644 index 000000000..d27d94cc3 --- /dev/null +++ b/frontend/portal/src/components/usage/PlanCard.stories.tsx @@ -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 = { + title: "Portal/Usage/PlanCard", + component: PlanCard, + args: { onSelect: () => {} }, + decorators: [ + (S) => ( +
    + +
    + ), + ], +}; +export default meta; +type Story = StoryObj; + +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 } }; diff --git a/frontend/portal/src/components/usage/PlanCard.tsx b/frontend/portal/src/components/usage/PlanCard.tsx new file mode 100644 index 000000000..4c745e8a8 --- /dev/null +++ b/frontend/portal/src/components/usage/PlanCard.tsx @@ -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 ( + +
    +

    {plan.name}

    + {isCurrent && ( + + Current + + )} +
    +
    + {plan.price} + + {plan.priceCadence} + +
    +

    {plan.blurb}

    +
      + {plan.features.map((f) => ( +
    • + + ✓ + + {f} +
    • + ))} +
    + +
    + ); +} diff --git a/frontend/portal/src/components/usage/SpendCapControl.stories.tsx b/frontend/portal/src/components/usage/SpendCapControl.stories.tsx new file mode 100644 index 000000000..0a426166e --- /dev/null +++ b/frontend/portal/src/components/usage/SpendCapControl.stories.tsx @@ -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 = { + title: "Portal/Usage/SpendCapControl", + component: SpendCapControl, + decorators: [ + (S) => ( +
    + +
    + ), + ], +}; +export default meta; +type Story = StoryObj; + +// 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" }, +}; diff --git a/frontend/portal/src/components/usage/SpendCapControl.tsx b/frontend/portal/src/components/usage/SpendCapControl.tsx new file mode 100644 index 000000000..1e5bf32eb --- /dev/null +++ b/frontend/portal/src/components/usage/SpendCapControl.tsx @@ -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 ( + +

    Spend cap

    +

    + 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. +

    +
    + ); + } + + if (tier === "enterprise") { + return ( + +

    Spend controls

    +

    + Spend is governed by your committed-volume contract. Overage terms and + alert thresholds are managed with your account team. +

    +
    + + Committed contract + + Overage billed at ${summary.overageRate.toFixed(3)}/doc +
    +
    + ); + } + + 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 ( + +
    +
    +

    Monthly spend cap

    +

    + Pause processing automatically when spend reaches your limit. +

    +
    + +
    + + {enabled && ( + <> +
    + USD.format(v)} + /> +
    +
    + + Projected {USD.format(projected)} of {USD.format(cap)} cap + + + {Math.round(capRatio * 100)}% + +
    + + + )} +
    + ); +} diff --git a/frontend/portal/src/components/usage/UpgradeModal.stories.tsx b/frontend/portal/src/components/usage/UpgradeModal.stories.tsx new file mode 100644 index 000000000..511006d30 --- /dev/null +++ b/frontend/portal/src/components/usage/UpgradeModal.stories.tsx @@ -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 = { + title: "Portal/Usage/UpgradeModal", + component: UpgradeModal, + args: { open: true, onClose: () => {}, target: null }, +}; +export default meta; +type Story = StoryObj; + +// 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" } }; diff --git a/frontend/portal/src/components/usage/UpgradeModal.tsx b/frontend/portal/src/components/usage/UpgradeModal.tsx new file mode 100644 index 000000000..0146a2aef --- /dev/null +++ b/frontend/portal/src/components/usage/UpgradeModal.tsx @@ -0,0 +1,133 @@ +import { Button, Modal } from "@shared/components"; +import type { Tier } from "@portal/contexts/TierContext"; +import type { PlanOption } from "@portal/api/usage"; +import "@portal/views/Usage.css"; + +interface UpgradeCopy { + title: string; + subtitle: string; + body: string; + bullets: string[]; + cta: string; + ctaAccent: "blue" | "purple"; +} + +/** + * Modal copy is intent-driven, not just plan-driven: a free user who has hit + * the cap sees urgency; a pro user is nudged toward a committed plan; an + * enterprise user is routed to their account team for bespoke terms. + */ +function upgradeCopy( + currentTier: Tier, + target: PlanOption | null, +): UpgradeCopy { + // Cap-reached: free user pushed to pay-as-you-go. + if (currentTier === "free") { + return { + title: "Upgrade to keep processing", + subtitle: "Pay-as-you-go · $0.05 / doc", + body: "You're at the edge of the 500 doc/month free cap. Pay-as-you-go lifts the cap instantly — you only pay for what you process beyond the included 25,000 docs.", + bullets: [ + "Lift the 500 doc/month cap immediately", + "25,000 docs included, then $0.05/doc", + "Unlimited pipelines, agents, and sources", + "Set a monthly spend cap to stay in control", + ], + cta: "Switch to pay-as-you-go", + ctaAccent: "blue", + }; + } + + // Commit-recommend: pro user with overage nudged to a committed plan. + if (currentTier === "pro") { + if (target?.tier === "enterprise") { + return { + title: "Move to a committed plan", + subtitle: "Enterprise · committed annual volume", + body: "Your overage is consistent month over month. A committed-volume contract lowers your effective per-doc rate and unlocks dedicated regions, SSO, and a named CSM.", + bullets: [ + "Lower effective rate vs metered overage", + "Dedicated & on-prem region options", + "SSO, audit-log export, signed DPA", + "Named CSM and 99.99% SLA", + ], + cta: "Talk to sales", + ctaAccent: "purple", + }; + } + return { + title: "You're already on pay-as-you-go", + subtitle: "Considering a committed plan?", + body: "Pay-as-you-go scales with usage. If your volume is steady, a committed-volume contract typically lowers your effective per-doc rate.", + bullets: [ + "Predictable monthly spend", + "Lower effective per-doc rate at volume", + "Volume discounts kick in past 1M docs/mo", + ], + cta: "Explore committed pricing", + ctaAccent: "purple", + }; + } + + // Bespoke-enterprise: route to account team. + return { + title: "Adjust your commitment", + subtitle: "Enterprise · bespoke terms", + body: "Your plan is governed by a committed-volume contract. Changes to committed volume, regions, or terms are handled with your account team — they'll model the right shape with you.", + bullets: [ + "Re-model committed volume up or down", + "Add dedicated or on-prem regions", + "Adjust SLA, DPA, and overage terms", + ], + cta: "Contact your CSM", + ctaAccent: "purple", + }; +} + +/** Intent-aware plan-change / sales-conversation modal. */ +export function UpgradeModal({ + open, + onClose, + currentTier, + target, +}: { + open: boolean; + onClose: () => void; + currentTier: Tier; + target: PlanOption | null; +}) { + const copy = upgradeCopy(currentTier, target); + return ( + + + {/* TODO(backend): POST /v1/billing/plan-change { tier } (or hand off to + sales) — for now the CTA just dismisses the modal. */} + + + } + > +

    {copy.body}

    +
      + {copy.bullets.map((b) => ( +
    • + + ✓ + + {b} +
    • + ))} +
    +
    + ); +} diff --git a/frontend/portal/src/components/usage/UsageChart.stories.tsx b/frontend/portal/src/components/usage/UsageChart.stories.tsx new file mode 100644 index 000000000..51327a0db --- /dev/null +++ b/frontend/portal/src/components/usage/UsageChart.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse, delay } from "msw"; +import { UsageChart } from "@portal/components/usage/UsageChart"; +import { buildUsagePayload } from "@portal/mocks/usage"; + +const meta: Meta = { + title: "Portal/Usage/UsageChart", + component: UsageChart, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
    + +
    + ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Loading: Story = { + parameters: { + msw: { + handlers: [ + http.get("/v1/billing/usage", async () => { + await delay("infinite"); + return HttpResponse.json(buildUsagePayload()); + }), + ], + }, + }, +}; + +export const Empty: Story = { + parameters: { + msw: { + handlers: [ + http.get("/v1/billing/usage", () => + HttpResponse.json({ points: [], priorTotal: 0 }), + ), + ], + }, + }, +}; diff --git a/frontend/portal/src/components/usage/UsageChart.tsx b/frontend/portal/src/components/usage/UsageChart.tsx new file mode 100644 index 000000000..23838595c --- /dev/null +++ b/frontend/portal/src/components/usage/UsageChart.tsx @@ -0,0 +1,49 @@ +import { useMemo } from "react"; +import { EmptyState, Skeleton } from "@shared/components"; +import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; +import { fetchBillingUsage, type UsageSeriesResponse } from "@portal/api/usage"; +import { UsageAreaChart } from "@portal/components/UsageAreaChart"; +import "@portal/components/UsageAreaChart.css"; + +/** 30-day docs-processed area chart, with the period total and prior-period delta. */ +export function UsageChart() { + const state = useAsync(() => fetchBillingUsage(), []); + const { data: usage } = state; + const { isLoading } = useSectionFlags(state); + + const docs30d = useMemo( + () => usage?.points.reduce((sum, p) => sum + p.value, 0) ?? 0, + [usage], + ); + const deltaPct = useMemo(() => { + if (!usage || usage.priorTotal <= 0) return undefined; + return (docs30d - usage.priorTotal) / usage.priorTotal; + }, [usage, docs30d]); + + if (isLoading) { + return ( +
    + + + +
    + ); + } + + if (!usage || usage.points.length === 0) { + return ( + + ); + } + + return ( + + ); +} diff --git a/frontend/portal/src/components/usage/format.ts b/frontend/portal/src/components/usage/format.ts new file mode 100644 index 000000000..25497473d --- /dev/null +++ b/frontend/portal/src/components/usage/format.ts @@ -0,0 +1,15 @@ +/** Shared currency / date formatting for the Usage & Billing surface. */ + +export const USD = new Intl.NumberFormat(undefined, { + style: "currency", + currency: "USD", + maximumFractionDigits: 2, +}); + +export function formatBillingDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); +} diff --git a/frontend/portal/src/contexts/UIContext.tsx b/frontend/portal/src/contexts/UIContext.tsx index a2b5249f7..8d0400e18 100644 --- a/frontend/portal/src/contexts/UIContext.tsx +++ b/frontend/portal/src/contexts/UIContext.tsx @@ -16,6 +16,11 @@ interface UIContextValue { openAssistant: () => void; closeAssistant: () => void; toggleAssistant: () => void; + + /** Settings is a modal overlay, not a route. */ + settingsOpen: boolean; + openSettings: () => void; + closeSettings: () => void; } const UIContext = createContext(null); @@ -23,6 +28,7 @@ const UIContext = createContext(null); export function UIProvider({ children }: { children: ReactNode }) { const [searchOpen, setSearchOpen] = useState(false); const [assistantOpen, setAssistantOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); const value = useMemo( () => ({ @@ -35,8 +41,12 @@ export function UIProvider({ children }: { children: ReactNode }) { openAssistant: () => setAssistantOpen(true), closeAssistant: () => setAssistantOpen(false), toggleAssistant: () => setAssistantOpen((o) => !o), + + settingsOpen, + openSettings: () => setSettingsOpen(true), + closeSettings: () => setSettingsOpen(false), }), - [searchOpen, assistantOpen], + [searchOpen, assistantOpen, settingsOpen], ); return {children}; diff --git a/frontend/portal/src/mocks/docs.ts b/frontend/portal/src/mocks/docs.ts new file mode 100644 index 000000000..59e5e91d6 --- /dev/null +++ b/frontend/portal/src/mocks/docs.ts @@ -0,0 +1,414 @@ +/** + * Developer Docs fixtures and the types api/docs.ts shares with them. + * api/docs.ts imports the types; the MSW handlers in mocks/handlers/ serve the + * fixture data over the intercepted httpJson() calls. Components never reach + * into this module directly. + * + * Two payloads back the surface: + * - the left-hand nav tree (`buildDocsNav`), and + * - the data-driven reference content (`docsContentFor`) — code samples, + * SDK matrix, embeddable components, playbooks, agent skills, the error + * table, and the tier-scaled rate-limit grid. + * + * Rate limits scale with plan: free is throttled hard, pro lifts the ceiling, + * enterprise is negotiated ("Custom"). The rest of the content is tier-neutral. + * + * Once a real backend exists the MSW handlers stop being registered and these + * fixtures can be deleted (or kept as test seeds). + */ + +import type { Tier } from "@portal/contexts/TierContext"; +import type { CodeLang } from "@shared/components"; + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Navigation */ +/* ──────────────────────────────────────────────────────────────────────── */ + +/** A leaf entry in the docs nav — maps 1:1 to a content section. */ +export interface DocsNavItem { + /** Stable id used as the in-page section anchor. */ + id: string; + label: string; + /** Optional badge shown to the right of the label (e.g. "New", "Beta"). */ + badge?: string; +} + +/** A top-level grouping in the docs nav tree. */ +export interface DocsNavSection { + id: string; + label: string; + /** Single-glyph icon shown beside the section header. */ + icon: string; + items: DocsNavItem[]; +} + +export function buildDocsNav(): DocsNavSection[] { + return [ + { + id: "getting-started", + label: "Getting Started", + icon: "▶", + items: [ + { id: "quickstart", label: "Quickstart" }, + { id: "authentication", label: "Authentication" }, + { id: "rate-limits", label: "Rate limits & quotas" }, + ], + }, + { + id: "api-reference", + label: "API Reference", + icon: "{ }", + items: [ + { id: "endpoints", label: "Endpoints" }, + { id: "errors", label: "Errors" }, + { id: "webhooks", label: "Webhooks", badge: "Beta" }, + ], + }, + { + id: "sdks", + label: "SDKs", + icon: "◇", + items: [{ id: "sdk-overview", label: "Official SDKs" }], + }, + { + id: "components", + label: "Components", + icon: "▤", + items: [{ id: "component-library", label: "Drop-in viewers" }], + }, + { + id: "playbooks", + label: "Playbooks", + icon: "✦", + items: [{ id: "recipes", label: "Recipes" }], + }, + { + id: "skills", + label: "Skills", + icon: "✷", + items: [{ id: "skill-catalog", label: "Agent skills", badge: "New" }], + }, + ]; +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Reference content */ +/* ──────────────────────────────────────────────────────────────────────── */ + +/** One tab in a multi-language code snippet. */ +export interface CodeSample { + /** Stable key used as the snippet tab id. */ + key: string; + label: string; + lang: CodeLang; + code: string; +} + +/** Per-tier request ceilings rendered by the rate-limits section. */ +export interface RateLimit { + rpm: string; + burst: string; + concurrency: string; +} + +/** A single HTTP status row in the error table. */ +export interface ApiErrorRow { + code: string; + /** Severity colour — amber for recoverable, red for hard failures. */ + tone: "amber" | "red"; + meaning: string; +} + +export type SdkStatus = "ga" | "beta" | "deprecated"; + +/** An official client library in the SDK matrix. */ +export interface Sdk { + name: string; + /** Single-glyph icon shown beside the name. */ + icon: string; + install: string; + lang: CodeLang; + status: SdkStatus; +} + +/** An embeddable UI component in the drop-in viewer library. */ +export interface EmbedComponent { + name: string; + blurb: string; + /** Stack tag, e.g. "React" or "Web". */ + tag: string; +} + +/** A copy-paste, end-to-end pipeline recipe. */ +export interface Playbook { + title: string; + blurb: string; + /** Ordered stages rendered as a chip flow. */ + steps: string[]; + accent: "blue" | "purple" | "green"; +} + +/** A bundled, named agent capability — a deterministic op chain. */ +export interface AgentSkill { + name: string; + blurb: string; + /** Op chain shown as a mono string, e.g. "extract · validate". */ + ops: string; +} + +/** The complete data-driven docs payload for one tier. */ +export interface DocsContent { + quickstartSamples: CodeSample[]; + quickstartResponse: string; + rateLimit: RateLimit; + errors: ApiErrorRow[]; + sdks: Sdk[]; + components: EmbedComponent[]; + playbooks: Playbook[]; + skills: AgentSkill[]; +} + +const QUICKSTART_SAMPLES: CodeSample[] = [ + { + key: "curl", + label: "cURL", + lang: "bash", + code: `curl https://api.stirlingpdf.com/v1/invoice \\ + -H "Authorization: Bearer $STIRLING_API_KEY" \\ + -F "file=@invoice.pdf"`, + }, + { + key: "python", + label: "Python", + lang: "python", + code: `from stirling import Stirling + +client = Stirling(api_key="sk_live_...") + +result = client.extract( + endpoint="/v1/invoice", + file=open("invoice.pdf", "rb"), +) +print(result.fields["total"])`, + }, + { + key: "node", + label: "Node", + lang: "typescript", + code: `import { Stirling } from "@stirling/sdk"; + +const client = new Stirling({ apiKey: process.env.STIRLING_API_KEY }); + +const result = await client.extract({ + endpoint: "/v1/invoice", + file: await fs.readFile("invoice.pdf"), +}); +console.log(result.fields.total);`, + }, +]; + +const QUICKSTART_RESPONSE = `{ + "endpoint": "/v1/invoice", + "confidence": 0.98, + "fields": { + "vendor_name": "Northwind Traders", + "invoice_number": "INV-20418", + "date": "2026-05-31", + "total": 4820.00, + "payment_terms": "Net 30" + }, + "pages": 2, + "latency_ms": 412 +}`; + +const RATE_LIMITS: Record = { + free: { rpm: "60 / min", burst: "10", concurrency: "2" }, + pro: { rpm: "1,200 / min", burst: "200", concurrency: "25" }, + enterprise: { rpm: "Custom", burst: "Custom", concurrency: "Custom" }, +}; + +const ERRORS: ApiErrorRow[] = [ + { + code: "400", + tone: "amber", + meaning: "Malformed request or unsupported file type.", + }, + { code: "401", tone: "red", meaning: "Missing or invalid API key." }, + { + code: "402", + tone: "amber", + meaning: "Quota exhausted — upgrade or wait for reset.", + }, + { code: "422", tone: "amber", meaning: "Document failed schema validation." }, + { code: "429", tone: "amber", meaning: "Rate limited. Honour Retry-After." }, + { + code: "500", + tone: "red", + meaning: "Internal error — safe to retry with backoff.", + }, +]; + +const SDKS: Sdk[] = [ + { + name: "Python", + icon: "🐍", + install: "pip install stirling", + lang: "bash", + status: "ga", + }, + { + name: "Node / TypeScript", + icon: "⬢", + install: "npm install @stirling/sdk", + lang: "bash", + status: "ga", + }, + { + name: "Go", + icon: "◉", + install: "go get github.com/stirling/stirling-go", + lang: "bash", + status: "ga", + }, + { + name: "Ruby", + icon: "◆", + install: "gem install stirling", + lang: "bash", + status: "ga", + }, + { + name: "Java", + icon: "☕", + install: "implementation 'com.stirling:sdk:1.x'", + lang: "bash", + status: "ga", + }, + { + name: ".NET", + icon: "◈", + install: "dotnet add package Stirling", + lang: "bash", + status: "beta", + }, + // PHP client predates the typed-response rewrite; pinned, no new endpoints. + { + name: "PHP", + icon: "🐘", + install: "composer require stirling/stirling-php:^0.9", + lang: "bash", + status: "deprecated", + }, +]; + +const COMPONENTS: EmbedComponent[] = [ + { + name: "", + blurb: + "Render any extracted document with field overlays and confidence highlighting.", + tag: "React", + }, + { + name: "", + blurb: + "Drag-and-drop ingestion with client-side type detection and progress.", + tag: "React", + }, + { + name: "", + blurb: "Editable table bound to an endpoint schema with inline validation.", + tag: "React", + }, + { + name: "Web component", + blurb: " custom element for non-React stacks.", + tag: "Web", + }, +]; + +const PLAYBOOKS: Playbook[] = [ + { + title: "Invoice → ERP sync", + blurb: + "Extract invoices from an inbox and post matched line items to your ledger.", + steps: [ + "Email source", + "Extract /v1/invoice", + "Three-way match", + "POST to ERP", + ], + accent: "blue", + }, + { + title: "PII redaction at scale", + blurb: + "Sweep a document set for PII and write redacted copies to cold storage.", + steps: ["S3 source", "Detect PII", "Redact", "Store to bucket"], + accent: "purple", + }, + { + title: "Compliance evidence pack", + blurb: + "Bundle SOC 2 and audit reports into a verified, timestamped archive.", + steps: ["Batch upload", "Classify", "Validate schema", "Sign & archive"], + accent: "green", + }, + { + title: "Agent document tool", + blurb: + "Expose extraction as an MCP tool your agent can call deterministically.", + steps: ["Define tool", "Bind endpoint", "Run evals", "Ship to agent"], + accent: "purple", + }, +]; + +const SKILLS: AgentSkill[] = [ + { + name: "Extract & validate", + blurb: + "Pull structured fields and enforce the endpoint schema in one call.", + ops: "extract · validate", + }, + { + name: "PII sweep", + blurb: + "Detect and redact personal data before a document leaves your tenant.", + ops: "detect-pii · redact", + }, + { + name: "Trust & verify", + blurb: + "Check signatures, hashes, and tamper evidence on inbound documents.", + ops: "verify-signature · checksum", + }, + { + name: "Compliance pack", + blurb: + "Classify, validate, and archive regulatory documents with an audit trail.", + ops: "classify · validate · archive", + }, + { + name: "Format prep", + blurb: "Normalise scans — deskew, OCR, and split — ahead of extraction.", + ops: "ocr · deskew · split", + }, + { + name: "Summarise", + blurb: "Generate a grounded summary with citations back to source pages.", + ops: "summarise", + }, +]; + +/** The data-driven docs content for a tier — only rate limits vary by plan. */ +export function docsContentFor(tier: Tier): DocsContent { + return { + quickstartSamples: QUICKSTART_SAMPLES, + quickstartResponse: QUICKSTART_RESPONSE, + rateLimit: RATE_LIMITS[tier], + errors: ERRORS, + sdks: SDKS, + components: COMPONENTS, + playbooks: PLAYBOOKS, + skills: SKILLS, + }; +} diff --git a/frontend/portal/src/mocks/handlers/docs.ts b/frontend/portal/src/mocks/handlers/docs.ts new file mode 100644 index 000000000..e6e688fda --- /dev/null +++ b/frontend/portal/src/mocks/handlers/docs.ts @@ -0,0 +1,20 @@ +import { http, HttpResponse, delay } from "msw"; +import type { Tier } from "@portal/contexts/TierContext"; +import { buildDocsNav, docsContentFor } from "@portal/mocks/docs"; + +function tierFrom(request: Request): Tier { + const url = new URL(request.url); + return (url.searchParams.get("tier") ?? "pro") as Tier; +} + +export const docsHandlers = [ + http.get("/v1/docs/nav", async () => { + await delay(120); + return HttpResponse.json(buildDocsNav()); + }), + + http.get("/v1/docs/content", async ({ request }) => { + await delay(120); + return HttpResponse.json(docsContentFor(tierFrom(request))); + }), +]; diff --git a/frontend/portal/src/mocks/handlers/index.ts b/frontend/portal/src/mocks/handlers/index.ts index 5f8afcf03..3ed6279f0 100644 --- a/frontend/portal/src/mocks/handlers/index.ts +++ b/frontend/portal/src/mocks/handlers/index.ts @@ -4,6 +4,12 @@ import { homeHandlers } from "@portal/mocks/handlers/home"; import { notificationsHandlers } from "@portal/mocks/handlers/notifications"; import { opsHandlers } from "@portal/mocks/handlers/ops"; import { searchHandlers } from "@portal/mocks/handlers/search"; +import { pipelinesHandlers } from "@portal/mocks/handlers/pipelines"; +import { sourcesHandlers } from "@portal/mocks/handlers/sources"; +import { infrastructureHandlers } from "@portal/mocks/handlers/infrastructure"; +import { usageHandlers } from "@portal/mocks/handlers/usage"; +import { docsHandlers } from "@portal/mocks/handlers/docs"; +import { settingsHandlers } from "@portal/mocks/handlers/settings"; export const handlers = [ ...homeHandlers, @@ -12,6 +18,12 @@ export const handlers = [ ...assistantHandlers, ...searchHandlers, ...endpointsHandlers, + ...pipelinesHandlers, + ...sourcesHandlers, + ...infrastructureHandlers, + ...usageHandlers, + ...docsHandlers, + ...settingsHandlers, ]; export { resetNotificationsStore } from "@portal/mocks/handlers/notifications"; diff --git a/frontend/portal/src/mocks/handlers/infrastructure.ts b/frontend/portal/src/mocks/handlers/infrastructure.ts new file mode 100644 index 000000000..5262eb4ff --- /dev/null +++ b/frontend/portal/src/mocks/handlers/infrastructure.ts @@ -0,0 +1,46 @@ +import { http, HttpResponse, delay } from "msw"; +import type { Tier } from "@portal/contexts/TierContext"; +import { + apiKeysFor, + auditLogFor, + recentDeploymentsFor, + regionsFor, + securityFor, + storageFor, +} from "@portal/mocks/infrastructure"; + +function tierFrom(request: Request): Tier { + const url = new URL(request.url); + return (url.searchParams.get("tier") ?? "pro") as Tier; +} + +export const infrastructureHandlers = [ + http.get("/v1/infrastructure/deployments", async ({ request }) => { + await delay(120); + const tier = tierFrom(request); + return HttpResponse.json({ + regions: regionsFor(tier), + recent: recentDeploymentsFor(tier), + }); + }), + + http.get("/v1/infrastructure/api-keys", async ({ request }) => { + await delay(120); + return HttpResponse.json(apiKeysFor(tierFrom(request))); + }), + + http.get("/v1/infrastructure/security", async ({ request }) => { + await delay(120); + return HttpResponse.json(securityFor(tierFrom(request))); + }), + + http.get("/v1/infrastructure/storage", async ({ request }) => { + await delay(120); + return HttpResponse.json(storageFor(tierFrom(request))); + }), + + http.get("/v1/infrastructure/audit-log", async ({ request }) => { + await delay(120); + return HttpResponse.json(auditLogFor(tierFrom(request))); + }), +]; diff --git a/frontend/portal/src/mocks/handlers/pipelines.ts b/frontend/portal/src/mocks/handlers/pipelines.ts new file mode 100644 index 000000000..9e28f8d41 --- /dev/null +++ b/frontend/portal/src/mocks/handlers/pipelines.ts @@ -0,0 +1,12 @@ +import { http, HttpResponse, delay } from "msw"; +import type { Tier } from "@portal/contexts/TierContext"; +import { pipelinesFor } from "@portal/mocks/pipelines"; + +export const pipelinesHandlers = [ + http.get("/v1/pipelines", async ({ request }) => { + await delay(120); + const url = new URL(request.url); + const tier = (url.searchParams.get("tier") ?? "pro") as Tier; + return HttpResponse.json(pipelinesFor(tier)); + }), +]; diff --git a/frontend/portal/src/mocks/handlers/settings.ts b/frontend/portal/src/mocks/handlers/settings.ts new file mode 100644 index 000000000..8fdd4c02f --- /dev/null +++ b/frontend/portal/src/mocks/handlers/settings.ts @@ -0,0 +1,12 @@ +import { http, HttpResponse, delay } from "msw"; +import type { Tier } from "@portal/contexts/TierContext"; +import { buildSettingsSnapshot } from "@portal/mocks/settings"; + +export const settingsHandlers = [ + http.get("/v1/settings", async ({ request }) => { + await delay(120); + const url = new URL(request.url); + const tier = (url.searchParams.get("tier") ?? "pro") as Tier; + return HttpResponse.json(buildSettingsSnapshot(tier)); + }), +]; diff --git a/frontend/portal/src/mocks/handlers/sources.ts b/frontend/portal/src/mocks/handlers/sources.ts new file mode 100644 index 000000000..d44322e99 --- /dev/null +++ b/frontend/portal/src/mocks/handlers/sources.ts @@ -0,0 +1,12 @@ +import { http, HttpResponse, delay } from "msw"; +import type { Tier } from "@portal/contexts/TierContext"; +import { buildSourcesResponse } from "@portal/mocks/sources"; + +export const sourcesHandlers = [ + http.get("/v1/sources", async ({ request }) => { + await delay(120); + const url = new URL(request.url); + const tier = (url.searchParams.get("tier") ?? "pro") as Tier; + return HttpResponse.json(buildSourcesResponse(tier)); + }), +]; diff --git a/frontend/portal/src/mocks/handlers/usage.ts b/frontend/portal/src/mocks/handlers/usage.ts new file mode 100644 index 000000000..82859349b --- /dev/null +++ b/frontend/portal/src/mocks/handlers/usage.ts @@ -0,0 +1,35 @@ +import { http, HttpResponse, delay } from "msw"; +import type { Tier } from "@portal/contexts/TierContext"; +import { + buildBillingHistory, + buildBillingSummary, + buildUsagePayload, + PLAN_OPTIONS, +} from "@portal/mocks/usage"; + +function tierFrom(request: Request): Tier { + const url = new URL(request.url); + return (url.searchParams.get("tier") ?? "pro") as Tier; +} + +export const usageHandlers = [ + http.get("/v1/billing/usage", async () => { + await delay(120); + return HttpResponse.json(buildUsagePayload()); + }), + + http.get("/v1/billing/summary", async ({ request }) => { + await delay(120); + return HttpResponse.json(buildBillingSummary(tierFrom(request))); + }), + + http.get("/v1/billing/plans", async () => { + await delay(120); + return HttpResponse.json(PLAN_OPTIONS); + }), + + http.get("/v1/billing/history", async ({ request }) => { + await delay(120); + return HttpResponse.json(buildBillingHistory(tierFrom(request))); + }), +]; diff --git a/frontend/portal/src/mocks/infrastructure.ts b/frontend/portal/src/mocks/infrastructure.ts new file mode 100644 index 000000000..a6f945d31 --- /dev/null +++ b/frontend/portal/src/mocks/infrastructure.ts @@ -0,0 +1,648 @@ +/** + * Infrastructure surface fixtures and the types api/infrastructure.ts shares + * with them. api/infrastructure.ts imports the types; the MSW handlers in + * mocks/handlers/ serve the fixture data over the intercepted httpJson() calls. + * Components never reach into this module directly. + * + * Everything here is tier-scaled deterministically: free sees a single region + * and a trimmed slice of every list, pro adds a second region and the IP + * allowlist, enterprise unlocks the full topology + compliance posture. + * + * Once a real backend exists the MSW handlers stop being registered and these + * fixtures can be deleted (or kept as test seeds). + */ + +import type { Tier } from "@portal/contexts/TierContext"; + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Deployments */ +/* ──────────────────────────────────────────────────────────────────────── */ + +export type RegionStatus = "healthy" | "degraded" | "down"; + +export interface DeploymentRegion { + name: string; + code: string; + /** Median request latency, ms. */ + latencyMs: number; + /** Current load as a fraction of provisioned capacity (0–1). */ + load: number; + status: RegionStatus; + /** Deployed Stirling engine version. */ + version: string; + /** 30-day uptime as a fraction (0–1). */ + uptime: number; + /** Running instance count. */ + instances: number; + /** Sustained throughput, docs/min. */ + throughput: number; + /** P99 latency, ms. */ + p99Ms: number; +} + +export type DeploymentStatus = "live" | "rolling" | "rolled-back" | "queued"; + +export interface RecentDeployment { + id: string; + version: string; + environment: "production" | "staging" | "canary"; + product: string; + status: DeploymentStatus; + deployedBy: string; + timestamp: string; +} + +const REGION_US_EAST: DeploymentRegion = { + name: "US East (N. Virginia)", + code: "us-east-1", + latencyMs: 41, + load: 0.62, + status: "healthy", + version: "v3.4.2", + uptime: 0.99987, + instances: 18, + throughput: 2140, + p99Ms: 287, +}; + +const REGION_US_WEST: DeploymentRegion = { + name: "US West (Oregon)", + code: "us-west-2", + latencyMs: 53, + load: 0.74, + status: "healthy", + version: "v3.4.2", + uptime: 0.99971, + instances: 12, + throughput: 1480, + p99Ms: 331, +}; + +const REGION_EU_WEST: DeploymentRegion = { + name: "EU West (Ireland)", + code: "eu-west-1", + latencyMs: 68, + load: 0.83, + status: "degraded", + version: "v3.4.1", + uptime: 0.99924, + instances: 9, + throughput: 1120, + p99Ms: 521, +}; + +// A region mid-incident: zero healthy instances, traffic drained away. +const REGION_AP_SOUTHEAST: DeploymentRegion = { + name: "Asia Pacific (Singapore)", + code: "ap-southeast-1", + latencyMs: 0, + load: 0, + status: "down", + version: "v3.4.1", + uptime: 0.98612, + instances: 0, + throughput: 0, + p99Ms: 0, +}; + +export function regionsFor(tier: Tier): DeploymentRegion[] { + if (tier === "free") return [REGION_US_EAST]; + if (tier === "pro") return [REGION_US_EAST, REGION_US_WEST]; + return [REGION_US_EAST, REGION_US_WEST, REGION_EU_WEST, REGION_AP_SOUTHEAST]; +} + +const RECENT_DEPLOYMENTS_ALL: RecentDeployment[] = [ + { + id: "dep-1", + version: "v3.4.2", + environment: "production", + product: "Extraction engine", + status: "live", + deployedBy: "ci-bot", + timestamp: "8m ago", + }, + { + id: "dep-2", + version: "v3.4.2-rc4", + environment: "canary", + product: "Redaction service", + status: "rolling", + deployedBy: "maria.chen", + timestamp: "34m ago", + }, + { + id: "dep-3", + version: "v2.9.0", + environment: "production", + product: "API gateway", + status: "live", + deployedBy: "ci-bot", + timestamp: "2h ago", + }, + { + id: "dep-4", + version: "v3.4.1", + environment: "staging", + product: "OCR worker pool", + status: "queued", + deployedBy: "devon.park", + timestamp: "3h ago", + }, + { + id: "dep-5", + version: "v3.3.9", + environment: "production", + product: "Extraction engine", + status: "rolled-back", + deployedBy: "maria.chen", + timestamp: "yesterday", + }, + { + id: "dep-6", + version: "v3.3.8", + environment: "production", + product: "Webhook dispatcher", + status: "live", + deployedBy: "ci-bot", + timestamp: "2d ago", + }, +]; + +export function recentDeploymentsFor(tier: Tier): RecentDeployment[] { + if (tier === "free") return RECENT_DEPLOYMENTS_ALL.slice(0, 2); + if (tier === "pro") return RECENT_DEPLOYMENTS_ALL.slice(0, 4); + return RECENT_DEPLOYMENTS_ALL; +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* API Keys */ +/* ──────────────────────────────────────────────────────────────────────── */ + +export type ApiKeyStatus = "active" | "revoked" | "rotate-soon"; +export type ApiKeyPermission = "Read" | "Write" | "Admin"; + +export interface ApiKey { + id: string; + name: string; + /** Masked prefix shown in the list, e.g. "sk_live_a3f8…". */ + prefix: string; + created: string; + lastUsed: string; + status: ApiKeyStatus; + /** Requests/min ceiling. */ + rateLimit: number; + permissions: ApiKeyPermission[]; + allowedIps: string[]; + usageToday: number; + usageMonth: number; +} + +const API_KEYS_ALL: 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, + }, + { + id: "key-2", + name: "Analytics · read-only", + prefix: "sk_live_77be…", + created: "Jan 18, 2026", + lastUsed: "41m ago", + status: "active", + rateLimit: 300, + permissions: ["Read"], + allowedIps: [], + usageToday: 6120, + usageMonth: 188400, + }, + { + id: "key-3", + name: "Ops · admin (legacy)", + prefix: "sk_live_d901…", + created: "Aug 9, 2025", + lastUsed: "6d ago", + status: "rotate-soon", + rateLimit: 600, + permissions: ["Read", "Write", "Admin"], + allowedIps: ["203.0.113.7/32"], + usageToday: 0, + usageMonth: 14200, + }, + { + id: "key-4", + name: "Sandbox · webhook tester", + prefix: "sk_test_2c4a…", + created: "May 30, 2026", + lastUsed: "never", + status: "revoked", + rateLimit: 60, + permissions: ["Read"], + allowedIps: [], + usageToday: 0, + usageMonth: 0, + }, +]; + +export function apiKeysFor(tier: Tier): ApiKey[] { + if (tier === "free") return API_KEYS_ALL.slice(0, 1); + if (tier === "pro") return API_KEYS_ALL.slice(0, 3); + return API_KEYS_ALL; +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Security */ +/* ──────────────────────────────────────────────────────────────────────── */ + +export type AccessPolicy = "stirling" | "byok" | "hyok"; +export type DataResidency = "us" | "eu" | "apac"; +export type CertStatus = "certified" | "in-progress" | "not-started"; + +export interface ComplianceCert { + id: string; + name: string; + status: CertStatus; + detail: string; +} + +export interface IpAllowEntry { + id: string; + label: string; + cidr: string; + addedBy: string; + added: string; +} + +export interface SecurityConfig { + accessPolicy: AccessPolicy; + dataResidency: DataResidency; + certs: ComplianceCert[]; + ipAllowlist: IpAllowEntry[]; +} + +const CERTS_FULL: ComplianceCert[] = [ + { + id: "soc2", + name: "SOC 2 Type II", + status: "certified", + detail: "Audited Apr 2026 · Coalfire", + }, + { + id: "iso", + name: "ISO 27001", + status: "certified", + detail: "Cert #IS-774201 · valid to 2027", + }, + { + id: "hipaa", + name: "HIPAA", + status: "certified", + detail: "BAA available · PHI-eligible", + }, + { + id: "gdpr", + name: "GDPR", + status: "certified", + detail: "EU SCCs · DPA on file", + }, +]; + +const CERTS_FREE: ComplianceCert[] = [ + { + id: "soc2", + name: "SOC 2 Type II", + status: "certified", + detail: "Inherited — Stirling platform", + }, + { + id: "iso", + name: "ISO 27001", + status: "certified", + detail: "Inherited — Stirling platform", + }, + { + id: "hipaa", + name: "HIPAA", + status: "not-started", + detail: "Requires a paid plan + BAA", + }, + { + id: "gdpr", + name: "GDPR", + status: "in-progress", + detail: "Standard EU processing terms", + }, +]; + +const IP_ALLOWLIST: IpAllowEntry[] = [ + { + id: "ip-1", + label: "Corp VPN egress", + cidr: "52.14.0.0/16", + addedBy: "maria.chen", + added: "Feb 11, 2026", + }, + { + id: "ip-2", + label: "Data centre — IAD", + cidr: "18.221.0.0/16", + addedBy: "devon.park", + added: "Jan 4, 2026", + }, + { + id: "ip-3", + label: "On-call jump host", + cidr: "203.0.113.7/32", + addedBy: "maria.chen", + added: "Dec 19, 2025", + }, +]; + +export function securityFor(tier: Tier): SecurityConfig { + if (tier === "free") { + return { + accessPolicy: "stirling", + dataResidency: "us", + certs: CERTS_FREE, + ipAllowlist: [], + }; + } + if (tier === "pro") { + return { + accessPolicy: "byok", + dataResidency: "us", + certs: CERTS_FULL, + ipAllowlist: IP_ALLOWLIST.slice(0, 2), + }; + } + return { + accessPolicy: "hyok", + dataResidency: "eu", + certs: CERTS_FULL, + ipAllowlist: IP_ALLOWLIST, + }; +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Storage */ +/* ──────────────────────────────────────────────────────────────────────── */ + +export type RetentionWindow = "30" | "60" | "90" | "180" | "never"; + +export interface StorageProvider { + id: string; + name: string; + kind: "stirling" | "s3" | "azure"; + connected: boolean; + detail: string; + usedGb: number; +} + +export interface StorageConfig { + /** Total used storage, GB. */ + usedGb: number; + /** Quota ceiling, GB. */ + quotaGb: number; + retention: RetentionWindow; + providers: StorageProvider[]; +} + +const PROVIDERS_FULL: StorageProvider[] = [ + { + id: "stirling", + name: "Stirling Cloud", + kind: "stirling", + connected: true, + detail: "Primary vault · us-east-1", + usedGb: 612, + }, + { + 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, + }, +]; + +export function storageFor(tier: Tier): StorageConfig { + if (tier === "free") { + return { + usedGb: 4.2, + quotaGb: 5, + retention: "30", + providers: [PROVIDERS_FULL[0]], + }; + } + if (tier === "pro") { + return { + usedGb: 318, + quotaGb: 500, + retention: "90", + providers: [PROVIDERS_FULL[0], PROVIDERS_FULL[1]], + }; + } + return { + usedGb: 1000, + quotaGb: 2000, + retention: "180", + providers: PROVIDERS_FULL, + }; +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Audit Logs */ +/* ──────────────────────────────────────────────────────────────────────── */ + +export type AuditCategory = + | "auth" + | "config" + | "elevation" + | "processing" + | "security"; + +export type AuditStatus = "success" | "warning" | "danger" | "info"; + +export interface AuditEvent { + id: string; + timestamp: string; + category: AuditCategory; + action: string; + actor: string; + target: string; + status: AuditStatus; + latencyMs: number; +} + +const AUDIT_EVENTS_ALL: AuditEvent[] = [ + { + id: "a-1", + timestamp: "10:42:18", + category: "processing", + action: "Pipeline run completed", + actor: "ci-bot", + target: "COI Compliance", + status: "success", + latencyMs: 412, + }, + { + id: "a-2", + timestamp: "10:38:02", + category: "auth", + action: "API key authenticated", + actor: "sk_live_a3f8…", + target: "us-east-1 gateway", + status: "success", + latencyMs: 9, + }, + { + id: "a-3", + timestamp: "10:31:55", + category: "elevation", + action: "Admin role assumed", + actor: "maria.chen", + target: "Security settings", + status: "warning", + latencyMs: 21, + }, + { + id: "a-4", + timestamp: "10:24:40", + category: "config", + action: "Retention policy changed", + actor: "devon.park", + target: "30d → 90d", + status: "info", + latencyMs: 14, + }, + { + id: "a-5", + timestamp: "10:19:07", + category: "security", + action: "IP allowlist updated", + actor: "maria.chen", + target: "203.0.113.7/32", + status: "info", + latencyMs: 11, + }, + { + id: "a-6", + timestamp: "10:12:33", + category: "auth", + action: "Failed key authentication", + actor: "sk_live_d901…", + target: "us-west-2 gateway", + status: "danger", + latencyMs: 6, + }, + { + id: "a-7", + timestamp: "10:04:51", + category: "processing", + action: "Redaction job queued", + actor: "ci-bot", + target: "Prior Auth batch", + status: "success", + latencyMs: 188, + }, + { + id: "a-8", + timestamp: "09:58:12", + category: "elevation", + action: "Key rotation requested", + actor: "devon.park", + target: "Ops · admin (legacy)", + status: "warning", + latencyMs: 33, + }, + { + id: "a-9", + timestamp: "09:51:44", + category: "security", + action: "Access policy set to HYOK", + actor: "maria.chen", + target: "Document encryption", + status: "info", + latencyMs: 27, + }, + { + id: "a-10", + timestamp: "09:43:20", + category: "config", + action: "Region added", + actor: "ci-bot", + target: "eu-west-1", + status: "success", + latencyMs: 240, + }, + { + id: "a-11", + timestamp: "09:36:08", + category: "processing", + action: "Schema drift flagged", + actor: "extract-engine", + target: "Invoice v3", + status: "warning", + latencyMs: 95, + }, + { + id: "a-12", + timestamp: "09:28:55", + category: "auth", + action: "SSO session started", + actor: "devon.park", + target: "Okta · acme.com", + status: "success", + latencyMs: 18, + }, +]; + +export interface AuditSummary { + totalEvents: number; + processing: number; + elevation: number; + config: number; +} + +export interface AuditLogResponse { + summary: AuditSummary; + events: AuditEvent[]; +} + +export function auditLogFor(tier: Tier): AuditLogResponse { + const events = + tier === "free" + ? AUDIT_EVENTS_ALL.slice(0, 5) + : tier === "pro" + ? AUDIT_EVENTS_ALL.slice(0, 9) + : AUDIT_EVENTS_ALL; + + // Headline counts reflect a full 24h window, not just the visible slice — + // the table shows the most recent events, the metrics show the day's totals. + const scale = tier === "free" ? 1 : tier === "pro" ? 21 : 86; + const summary: AuditSummary = { + totalEvents: events.length * scale, + processing: + events.filter((e) => e.category === "processing").length * scale, + elevation: events.filter((e) => e.category === "elevation").length * scale, + config: events.filter((e) => e.category === "config").length * scale, + }; + return { summary, events }; +} diff --git a/frontend/portal/src/mocks/pipelines.ts b/frontend/portal/src/mocks/pipelines.ts new file mode 100644 index 000000000..b50d2688d --- /dev/null +++ b/frontend/portal/src/mocks/pipelines.ts @@ -0,0 +1,334 @@ +/** + * Pipelines fixtures and the types api/pipelines.ts shares with them. + * api/pipelines.ts imports the types; the MSW handlers in mocks/handlers/ + * serve the fixture data over the intercepted httpJson() calls. Components + * never reach into this module directly. + * + * Fixtures are tier-shaped: + * - free → empty (prompts "build your first pipeline") + * - pro → a small deployed fleet + * - enterprise → a larger fleet plus a shadow/comparative evals note + */ + +import type { Tier } from "@portal/contexts/TierContext"; + +/** A deployed pipeline's live health. */ +export type PipelineStatus = "healthy" | "degraded"; + +/** 24-hour rollup shown on the pipeline row. */ +export interface PipelineMetrics { + /** Docs processed in the trailing 24h. */ + docs24h: number; + /** Sustained throughput, docs/min. */ + throughputPerMin: number; + /** Error rate as a fraction (0.004 = 0.4%). */ + errorRate: number; + /** P95 stage-to-store latency, ms. */ + p95LatencyMs: number; + /** Uptime over the trailing 24h, as a fraction. */ + uptime: number; +} + +/** + * The five silent stages every pipeline passes a document through. The accent + * is fixed per stage so the chip row reads the same across every pipeline: + * Ingest=green, Validate=blue, Modify=amber, Secure=red, Route/Store=purple. + */ +export type StageKey = "ingest" | "validate" | "modify" | "secure" | "route"; + +export interface StageSummary { + key: StageKey; + label: string; + /** Op labels active in this stage for this pipeline. */ + ops: string[]; +} + +/** Golden-set validation rollup. */ +export interface GoldenSet { + passing: number; + total: number; + /** Last time the set was run. */ + lastRun: string; +} + +/** A single field whose shape has drifted from the inferred schema. */ +export interface SchemaDrift { + field: string; + /** Human summary of what changed. */ + note: string; + /** Confidence delta since the last known-good shape (negative = worse). */ + confidenceDelta: number; + severity: "info" | "warning"; + /** Share of docs in the window that exhibited the drift. */ + affectedDocs: number; +} + +export interface Pipeline { + id: string; + name: string; + /** What the pipeline does, one line. */ + blurb: string; + status: PipelineStatus; + /** Source rail label (from SOURCE_OPTIONS). */ + source: string; + /** Destination rail label (from DESTINATION_OPTIONS). */ + destination: string; + /** Deployed version tag. */ + version: string; + /** Regions the pipeline runs in. */ + regions: string[]; + metrics: PipelineMetrics; + stages: StageSummary[]; + golden: GoldenSet; + drift: SchemaDrift[]; +} + +const STAGE_LABEL: Record = { + ingest: "Ingest", + validate: "Validate", + modify: "Modify", + secure: "Secure", + route: "Route / Store", +}; + +/** Build a five-stage summary from the per-stage op-label lists. */ +function stages( + ingest: string[], + validate: string[], + modify: string[], + secure: string[], + route: string[], +): StageSummary[] { + const byKey: Record = { + ingest, + validate, + modify, + secure, + route, + }; + return (Object.keys(byKey) as StageKey[]).map((key) => ({ + key, + label: STAGE_LABEL[key], + ops: byKey[key], + })); +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Tier fixtures */ +/* ──────────────────────────────────────────────────────────────────────── */ + +const COI_COMPLIANCE: Pipeline = { + id: "pl-coi", + name: "COI Compliance", + blurb: "Certificate-of-insurance intake → coverage-gap check → vault", + status: "healthy", + source: "Email intake", + destination: "Stirling vault", + version: "v3.4.1", + regions: ["us-east-1", "eu-west-1"], + metrics: { + docs24h: 28941, + throughputPerMin: 22, + errorRate: 0.004, + p95LatencyMs: 412, + uptime: 0.9998, + }, + stages: stages( + ["OCR", "Classify", "Extract"], + ["Schema validate", "Confidence bounds"], + ["Compress"], + ["Redact PII", "Encryption at rest"], + ["Primary store", "Processing manifest"], + ), + golden: { passing: 36, total: 36, lastRun: "2h ago" }, + drift: [], +}; + +const PRIOR_AUTH: 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, + }, + stages: stages( + ["OCR", "Classify", "Extract"], + ["Schema validate", "Counterparty match", "Confidence bounds"], + ["Convert"], + ["Redact PII", "PII/PHI enforcement", "Encryption at rest"], + ["Conditional routing", "Human review"], + ), + 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, + }, + ], +}; + +const INVOICE_AP: 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: stages( + ["Parse", "Classify", "Extract"], + ["Schema validate", "Confidence bounds"], + ["PDF → CSV"], + ["Redact PII", "Encryption at rest"], + ["Primary store", "Mirror to bucket", "Notify"], + ), + golden: { passing: 41, total: 42, lastRun: "1h ago" }, + drift: [ + { + field: "tax", + note: "EU reverse-charge invoices omit a line-level tax field", + confidenceDelta: -0.02, + severity: "info", + affectedDocs: 4, + }, + ], +}; + +const CONTRACT_REVIEW: Pipeline = { + id: "pl-contract", + name: "Contract Review", + blurb: "Contract intake → clause-risk analysis → review queue", + status: "healthy", + source: "Upload API", + destination: "Another pipeline", + version: "v1.9.2", + regions: ["eu-west-1"], + metrics: { + docs24h: 4380, + throughputPerMin: 4, + errorRate: 0.009, + p95LatencyMs: 1280, + uptime: 0.9991, + }, + stages: stages( + ["OCR", "Classify"], + ["Contract analyzer", "Authenticity", "Confidence bounds"], + ["Document summarizer"], + ["Redact PII", "Confidentiality mark", "Signed outputs"], + ["Human review", "Flag"], + ), + golden: { passing: 31, total: 33, lastRun: "5h ago" }, + drift: [], +}; + +const KYC_PROCESSOR: Pipeline = { + id: "pl-kyc", + name: "KYC Processor", + blurb: + "Identity-document intake → authenticity + sanctions → compliance archive", + status: "healthy", + source: "Scheduled import", + destination: "Compliance archive", + version: "v4.0.3", + regions: ["us-east-1", "eu-west-1", "ap-southeast-1"], + metrics: { + docs24h: 19870, + throughputPerMin: 15, + errorRate: 0.005, + p95LatencyMs: 503, + uptime: 0.9999, + }, + stages: stages( + ["OCR", "Classify", "Extract"], + ["Authenticity", "Tamper check", "Counterparty match"], + ["Convert"], + ["Field-aware redact", "Attribution watermark", "Encryption at rest"], + ["Compliance archive", "Processing manifest", "Notify"], + ), + golden: { passing: 52, total: 54, lastRun: "31m ago" }, + drift: [ + { + field: "document_number", + note: "New passport series uses a 9-char alphanumeric format", + confidenceDelta: -0.04, + severity: "warning", + affectedDocs: 11, + }, + ], +}; + +/** + * Enterprise-only evals note. Surfaced as a banner: shadow + comparative eval + * runs gate every promotion, so the deployed fleet always trails a quietly + * running candidate. + */ +export interface EvalsNote { + /** Pipelines currently running a shadow eval against production traffic. */ + shadowCount: number; + /** Comparative (champion/challenger) runs awaiting sign-off. */ + comparativeCount: number; + detail: string; +} + +const ENTERPRISE_EVALS: EvalsNote = { + shadowCount: 2, + comparativeCount: 2, + detail: + "Prior Auth v3.2.0-rc and Invoice v2.9.0-rc are mirroring live traffic in shadow; KYC v4.1.0 is in a comparative run against v4.0.3. The Contract Review v2.0.0 candidate is blocked — it regressed 3 golden cases on clause-risk scoring, so the comparative run is held until the candidate is re-cut. No promotion happens until a candidate clears its golden set and the comparative delta stays inside bounds.", +}; + +export interface PipelinesResponse { + pipelines: Pipeline[]; + /** Present for enterprise only. */ + evals: EvalsNote | null; +} + +export function pipelinesFor(tier: Tier): PipelinesResponse { + if (tier === "free") { + return { pipelines: [], evals: null }; + } + if (tier === "enterprise") { + return { + pipelines: [ + INVOICE_AP, + COI_COMPLIANCE, + PRIOR_AUTH, + KYC_PROCESSOR, + CONTRACT_REVIEW, + ], + evals: ENTERPRISE_EVALS, + }; + } + // pro + return { + pipelines: [COI_COMPLIANCE, INVOICE_AP, PRIOR_AUTH], + evals: null, + }; +} diff --git a/frontend/portal/src/mocks/settings.ts b/frontend/portal/src/mocks/settings.ts new file mode 100644 index 000000000..7cd2afe98 --- /dev/null +++ b/frontend/portal/src/mocks/settings.ts @@ -0,0 +1,109 @@ +/** + * Account-settings fixtures and the types api/settings.ts shares with them. + * api/settings.ts imports the types; the MSW handlers in mocks/handlers/ serve + * the fixture data over the intercepted httpJson() call. Components never reach + * into this module directly. + * + * The shape is tier-aware: the workspace plan label, available regions, and + * data-residency posture differ by tier, so the modal reflects what each plan + * can actually configure. + */ + +import type { Tier } from "@portal/contexts/TierContext"; + +export interface RegionOption { + value: string; + label: string; + /** Enterprise-only residency regions are gated below higher tiers. */ + enterpriseOnly?: boolean; +} + +export interface NotificationDefault { + id: string; + enabled: boolean; +} + +/** + * Server snapshot of the account + workspace the modal opens onto. Editable + * fields seed local form state; `planLabel` / `seats` are read-only context. + */ +export interface SettingsSnapshot { + profile: { + name: string; + email: string; + role: string; + /** Avatar image URL, or null to fall back to initials. */ + avatarUrl: string | null; + }; + workspace: { + name: string; + region: string; + planLabel: string; + seats: { used: number; total: number }; + }; + /** Per-category notification toggles, server-default on/off. */ + notifications: NotificationDefault[]; + regions: RegionOption[]; +} + +const REGIONS: RegionOption[] = [ + { value: "us-east-1", label: "US East (N. Virginia)" }, + { value: "us-west-2", label: "US West (Oregon)" }, + { value: "eu-west-1", label: "EU West (Ireland)" }, + { value: "eu-central-1", label: "EU Central (Frankfurt)" }, + { + value: "ap-southeast-2", + label: "Asia Pacific (Sydney)", + enterpriseOnly: true, + }, + { value: "ca-central-1", label: "Canada (Central)", enterpriseOnly: true }, +]; + +const PLAN_LABEL: Record = { + free: "Free Plan", + pro: "Pay-as-you-go", + enterprise: "Enterprise Plan", +}; + +const SEATS: Record = { + free: { used: 1, total: 1 }, + pro: { used: 4, total: 5 }, + enterprise: { used: 38, total: 50 }, +}; + +const WORKSPACE_NAME: Record = { + free: "My Workspace", + pro: "Acme Document Ops", + enterprise: "Acme Corp — Global", +}; + +/** Notification categories shown in Preferences, with sensible per-tier defaults. */ +function notificationsFor(tier: Tier): NotificationDefault[] { + return [ + { id: "pipeline-failures", enabled: true }, + { id: "pipeline-success", enabled: tier !== "free" }, + { id: "usage-alerts", enabled: true }, + { id: "weekly-digest", enabled: tier === "free" }, + { id: "security-alerts", enabled: true }, + { id: "product-updates", enabled: false }, + ]; +} + +export function buildSettingsSnapshot(tier: Tier): SettingsSnapshot { + return { + profile: { + name: "Reece Browne", + email: "reece@stirlingpdf.com", + role: tier === "enterprise" ? "Org Admin" : "Owner", + avatarUrl: null, + }, + workspace: { + name: WORKSPACE_NAME[tier], + region: tier === "free" ? "us-east-1" : "eu-west-1", + planLabel: PLAN_LABEL[tier], + seats: SEATS[tier], + }, + notifications: notificationsFor(tier), + regions: REGIONS, + }; +} diff --git a/frontend/portal/src/mocks/sources.ts b/frontend/portal/src/mocks/sources.ts new file mode 100644 index 000000000..d881043a3 --- /dev/null +++ b/frontend/portal/src/mocks/sources.ts @@ -0,0 +1,545 @@ +/** + * Sources & Agents fixtures and the types api/sources.ts shares with them. + * + * A "source" is anything that feeds documents into Stirling — an interactive + * editor session, an autonomous agent, an API client, a webhook, a storage + * connector, an email inbox, a desktop app, or a batch job. Each carries a + * type-specific detail payload surfaced when its table row is expanded. + * + * api/sources.ts imports the types; the MSW handlers serve the fixture data + * over the intercepted httpJson() calls. Components never reach into this + * module directly. Once a real backend exists the handlers stop being + * registered and these fixtures can be deleted (or kept as test seeds). + */ + +import type { Tier } from "@portal/contexts/TierContext"; + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Source types */ +/* ──────────────────────────────────────────────────────────────────────── */ + +export type SourceType = + | "editor" + | "agent" + | "apiclient" + | "webhook" + | "connector" + | "email" + | "desktop" + | "batch"; + +export type SourceStatus = "active" | "idle" | "degraded" | "paused" | "error"; + +/** Type-specific detail payloads, discriminated by `kind`. */ + +export interface AgentDetail { + kind: "agent"; + model: string; + /** Calls over the trailing 24h. */ + calls24h: number; + errorRate: number; + /** Mean output confidence 0..1. */ + confidence: number; + escalations24h: number; + /** Pipelines this agent is allowed to invoke. */ + assignedPipelines: string[]; + scopes: string[]; +} + +export interface ApiClientDetail { + kind: "apiclient"; + /** Pre-masked for display — never carries the real secret. */ + maskedKey: string; + rateLimit: string; + /** Requests used against the current rate-limit window. */ + rateUsedPct: number; + endpoints: { method: string; path: string; calls24h: number }[]; + createdBy: string; + lastRotated: string; +} + +export interface WebhookDetail { + kind: "webhook"; + url: string; + authType: "HMAC-SHA256" | "Bearer token" | "Basic" | "None"; + successRate: number; + /** Recent deliveries, newest first. */ + recentDeliveries: { event: string; status: number; time: string }[]; + retries24h: number; +} + +/** Generic key/value detail for the simpler source types. */ +export interface BasicDetail { + kind: "basic"; + rows: { label: string; value: string }[]; +} + +export type SourceDetail = + | AgentDetail + | ApiClientDetail + | WebhookDetail + | BasicDetail; + +export interface Source { + id: string; + name: string; + type: SourceType; + status: SourceStatus; + docs24h: number; + docs30d: number; + /** Relative-time string, e.g. "2m ago". */ + lastEvent: string; + owner: string; + detail: SourceDetail; +} + +export interface SourcesKpi { + value: string | number; + delta?: number; + deltaDirection?: "up" | "down" | "flat"; + description?: string; +} + +export interface SourcesResponse { + kpis: SourcesKpi[]; + sources: Source[]; +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Per-type presentation metadata (icon + chip tone + label) */ +/* Lives client-side — it's product copy, not data. Re-exported for the */ +/* view via api/sources.ts. */ +/* ──────────────────────────────────────────────────────────────────────── */ + +export interface SourceTypeMeta { + label: string; + icon: string; + tone: "neutral" | "blue" | "purple" | "green" | "amber" | "red"; +} + +export const SOURCE_TYPE_META: Record = { + editor: { label: "Editor", icon: "✎", tone: "neutral" }, + agent: { label: "Agent", icon: "◆", tone: "purple" }, + apiclient: { label: "API client", icon: "⌘", tone: "blue" }, + webhook: { label: "Webhook", icon: "⇄", tone: "green" }, + connector: { label: "Connector", icon: "⛁", tone: "amber" }, + email: { label: "Email inbox", icon: "✉", tone: "blue" }, + desktop: { label: "Desktop", icon: "▣", tone: "neutral" }, + batch: { label: "Batch job", icon: "≡", tone: "amber" }, +}; + +export const SOURCE_STATUS_TONE: Record< + SourceStatus, + "success" | "warning" | "danger" | "neutral" | "info" +> = { + active: "success", + idle: "neutral", + degraded: "warning", + paused: "info", + error: "danger", +}; + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Fixture builders */ +/* ──────────────────────────────────────────────────────────────────────── */ + +function agentDetail(over: Partial): AgentDetail { + return { + kind: "agent", + model: "claude-sonnet-4.5", + calls24h: 0, + errorRate: 0, + confidence: 0.95, + escalations24h: 0, + assignedPipelines: [], + scopes: [], + ...over, + }; +} + +const PRO_SOURCES: Source[] = [ + { + id: "src-editor-1", + name: "Web Editor — workspace", + type: "editor", + status: "active", + docs24h: 38, + docs30d: 642, + lastEvent: "4m ago", + owner: "you@acme.com", + detail: { + kind: "basic", + rows: [ + { label: "Session", value: "Browser · Chrome 126" }, + { label: "Active editors", value: "3 this week" }, + { label: "Default pipeline", value: "Redact & Flatten" }, + { label: "Region", value: "us-east-1" }, + ], + }, + }, + { + id: "src-agent-1", + name: "Invoice Extractor", + type: "agent", + status: "active", + docs24h: 1287, + docs30d: 31840, + lastEvent: "32s ago", + owner: "platform@acme.com", + detail: agentDetail({ + 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"], + }), + }, + { + id: "src-agent-2", + name: "Contract Router", + type: "agent", + status: "degraded", + docs24h: 412, + docs30d: 9870, + lastEvent: "6m ago", + owner: "legal-ops@acme.com", + // Degraded: error rate over the 5% alarm threshold and confidence below the + // green band, so the panel renders danger/amber tones. + detail: agentDetail({ + model: "claude-opus-4.1", + calls24h: 455, + errorRate: 0.071, + confidence: 0.883, + escalations24h: 34, + assignedPipelines: ["Contract Review", "DPA Classifier"], + scopes: ["documents:read", "pipelines:invoke", "review:escalate"], + }), + }, + { + id: "src-agent-3", + name: "KYC Processor", + type: "agent", + status: "active", + docs24h: 768, + docs30d: 18420, + lastEvent: "1m ago", + owner: "risk@acme.com", + detail: agentDetail({ + model: "claude-sonnet-4.5", + calls24h: 802, + errorRate: 0.012, + confidence: 0.941, + escalations24h: 7, + assignedPipelines: ["KYC Onboarding"], + scopes: ["documents:read", "pipelines:invoke", "pii:read"], + }), + }, + { + id: "src-api-1", + name: "Acme Production", + type: "apiclient", + status: "active", + docs24h: 2940, + docs30d: 71200, + lastEvent: "11s ago", + owner: "platform@acme.com", + detail: { + 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: "you@acme.com", + lastRotated: "23 days ago", + }, + }, + { + id: "src-webhook-1", + name: "Slack delivery hook", + type: "webhook", + status: "active", + docs24h: 96, + docs30d: 2310, + lastEvent: "9m ago", + owner: "platform@acme.com", + detail: { + 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" }, + ], + }, + }, + { + id: "src-webhook-err", + name: "Legacy ERP callback", + type: "webhook", + status: "error", + docs24h: 41, + docs30d: 1180, + lastEvent: "2m ago", + owner: "integrations@acme.com", + // Endpoint is rejecting most deliveries — success rate below the 95% floor + // drives the danger tone and the retry count climbs. + detail: { + kind: "webhook", + 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" }, + ], + }, + }, + { + id: "src-batch-1", + name: "Nightly archive reprocess", + type: "batch", + status: "idle", + docs24h: 0, + docs30d: 48600, + lastEvent: "8h ago", + owner: "data-eng@acme.com", + detail: { + kind: "basic", + rows: [ + { label: "Schedule", value: "Daily · 02:00 UTC" }, + { label: "Last run", value: "1,620 docs · 0 errors" }, + { label: "Source bucket", value: "s3://acme-archive/inbound" }, + { label: "Pipeline", value: "OCR & Index" }, + ], + }, + }, +]; + +const ENTERPRISE_EXTRA: Source[] = [ + { + id: "src-connector-1", + name: "SharePoint — Legal", + type: "connector", + status: "active", + docs24h: 1840, + docs30d: 44900, + lastEvent: "2m ago", + owner: "legal-ops@acme.com", + detail: { + kind: "basic", + rows: [ + { label: "Provider", value: "Microsoft SharePoint" }, + { label: "Sync", value: "Delta · every 5 min" }, + { label: "Watched library", value: "Contracts / Inbound" }, + { label: "Auth", value: "Azure AD app registration" }, + ], + }, + }, + { + id: "src-connector-2", + name: "S3 — claims-intake", + type: "connector", + status: "active", + docs24h: 5210, + docs30d: 128400, + lastEvent: "40s ago", + owner: "claims@acme.com", + detail: { + kind: "basic", + rows: [ + { label: "Provider", value: "AWS S3" }, + { label: "Bucket", value: "s3://acme-claims/intake" }, + { label: "Notification", value: "EventBridge → SQS" }, + { label: "Region", value: "us-east-1" }, + ], + }, + }, + { + id: "src-email-1", + name: "invoices@acme.com", + type: "email", + status: "active", + docs24h: 318, + docs30d: 7640, + lastEvent: "14m ago", + owner: "ap@acme.com", + detail: { + kind: "basic", + rows: [ + { label: "Inbox", value: "invoices@acme.com" }, + { label: "Attachments", value: "PDF only · max 25 MB" }, + { label: "Pipeline", value: "Invoice v3" }, + { label: "Spam filter", value: "Enabled · DKIM verified" }, + ], + }, + }, + { + id: "src-desktop-1", + name: "Stirling Desktop — Reviewer pool", + type: "desktop", + status: "idle", + docs24h: 47, + docs30d: 1290, + lastEvent: "3h ago", + owner: "review-team@acme.com", + detail: { + kind: "basic", + rows: [ + { label: "App version", value: "Desktop 2.7.1" }, + { label: "Seats", value: "12 active devices" }, + { label: "Default pipeline", value: "Manual Review" }, + { label: "Offline queue", value: "Enabled" }, + ], + }, + }, + { + id: "src-agent-4", + name: "Compliance Sweep", + type: "agent", + status: "active", + docs24h: 2105, + docs30d: 52300, + lastEvent: "18s ago", + owner: "compliance@acme.com", + detail: agentDetail({ + model: "claude-opus-4.1", + calls24h: 2180, + errorRate: 0.008, + confidence: 0.957, + escalations24h: 19, + assignedPipelines: ["COI Compliance", "PII Sweep", "Retention Policy"], + scopes: ["documents:read", "pipelines:invoke", "pii:read", "audit:write"], + }), + }, + { + id: "src-webhook-2", + name: "Datadog events hook", + type: "webhook", + status: "active", + docs24h: 410, + docs30d: 9800, + lastEvent: "1m ago", + owner: "sre@acme.com", + detail: { + kind: "webhook", + url: "https://intake.datadoghq.com/stirling/events", + authType: "Bearer token", + successRate: 0.999, + retries24h: 0, + recentDeliveries: [ + { event: "pipeline.completed", status: 202, time: "1m ago" }, + { event: "agent.escalated", status: 202, time: "12m ago" }, + { event: "pipeline.failed", status: 202, time: "47m ago" }, + ], + }, + }, + { + id: "src-api-revoked", + name: "Legacy ETL (revoked)", + type: "apiclient", + status: "error", + docs24h: 0, + docs30d: 3120, + lastEvent: "5d ago", + owner: "data-eng@acme.com", + // Key was revoked after a leak; calls now 401 so 24h traffic is zero while + // the 30d window still shows the pre-revocation volume. + detail: { + kind: "apiclient", + maskedKey: "sk_live_••••••••••••0000 (revoked)", + rateLimit: "300 req/min", + rateUsedPct: 0, + endpoints: [ + { method: "POST", path: "/v1/extract", calls24h: 0 }, + { method: "POST", path: "/v1/redact", calls24h: 0 }, + ], + createdBy: "former-admin@acme.com", + lastRotated: "never", + }, + }, + { + id: "src-api-2", + name: "Partner Integration (read-only)", + type: "apiclient", + status: "paused", + docs24h: 0, + docs30d: 14200, + lastEvent: "2d ago", + owner: "partnerships@acme.com", + detail: { + kind: "apiclient", + maskedKey: "sk_live_••••••••••••91c2", + rateLimit: "120 req/min", + rateUsedPct: 0, + endpoints: [ + { method: "GET", path: "/v1/documents/{id}", calls24h: 0 }, + { method: "GET", path: "/v1/pipelines", calls24h: 0 }, + ], + createdBy: "you@acme.com", + lastRotated: "61 days ago", + }, + }, +]; + +/** Sources for a given tier. Free is intentionally empty. */ +export function sourcesFor(tier: Tier): Source[] { + if (tier === "free") return []; + if (tier === "enterprise") return [...PRO_SOURCES, ...ENTERPRISE_EXTRA]; + return PRO_SOURCES; +} + +/** + * KPI strip values. Pro sits below the eval pass-rate target, enterprise above — + * the delta/direction differences let a dev see tier variation at a glance. + */ +export function kpisFor(tier: Tier): SourcesKpi[] { + if (tier === "free") { + return [ + { value: 0, description: "Connect a source to begin" }, + { value: 0 }, + { value: "—" }, + { value: 0 }, + ]; + } + + const sources = sourcesFor(tier); + const agents = sources.filter((s) => s.type === "agent"); + const agentsActive = agents.filter((s) => s.status === "active").length; + const docs24h = sources.reduce((sum, s) => sum + s.docs24h, 0); + + if (tier === "enterprise") { + return [ + { + value: agentsActive, + delta: 0.25, + description: `${agents.length} total`, + }, + { value: 42, delta: 0.09, description: "Eval scenarios" }, + { value: "96.4%", deltaDirection: "up", delta: 0.012 }, + { value: docs24h.toLocaleString(), delta: 0.14 }, + ]; + } + + // pro + return [ + { value: agentsActive, delta: 0.5, description: `${agents.length} total` }, + { value: 18, delta: 0.2, description: "Eval scenarios" }, + { value: "91.2%", deltaDirection: "flat", delta: 0 }, + { value: docs24h.toLocaleString(), delta: 0.16 }, + ]; +} + +export function buildSourcesResponse(tier: Tier): SourcesResponse { + return { kpis: kpisFor(tier), sources: sourcesFor(tier) }; +} diff --git a/frontend/portal/src/mocks/usage.ts b/frontend/portal/src/mocks/usage.ts new file mode 100644 index 000000000..8fb606854 --- /dev/null +++ b/frontend/portal/src/mocks/usage.ts @@ -0,0 +1,341 @@ +/** + * Usage & Billing fixtures and the types api/usage.ts shares with them. + * api/usage.ts imports the types; the MSW handlers in mocks/handlers/usage.ts + * serve this fixture data over the intercepted httpJson() calls. Components + * never reach into this module directly. + * + * Once a real billing backend exists, the MSW handlers stop being registered + * and these fixtures can be deleted (or kept as test seeds). + */ + +import type { Tier } from "@portal/contexts/TierContext"; +import { + buildUsageSeries, + buildUsageSeriesResponse, + type UsageSeriesResponse, +} from "@portal/mocks/home"; + +export type { UsagePoint, UsageSeriesResponse } from "@portal/mocks/home"; + +/** Per-doc rate charged once the free cap is exceeded (pay-as-you-go). */ +export const OVERAGE_RATE = 0.05; + +/** Documents included before overage / cap kicks in, per tier. */ +export const TIER_DOC_CAP: Record = { + free: 500, + pro: 25_000, + enterprise: 2_000_000, +}; + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Billing summary (KPI strip + plan card) */ +/* ──────────────────────────────────────────────────────────────────────── */ + +export interface BillingSummary { + tier: Tier; + /** Human plan name shown on the current-plan card. */ + planName: string; + /** Docs processed in the current billing period. */ + docsThisPeriod: number; + /** Included docs before overage / cap. */ + includedDocs: number; + /** Cost accrued this month, in USD. */ + costThisMonth: number; + /** Overage docs past the included cap (0 when under). */ + overageDocs: number; + /** Overage cost this month, in USD. */ + overageCost: number; + /** Per-doc overage rate, in USD. */ + overageRate: number; + /** Fixed monthly platform fee, in USD (0 for free / usage-only). */ + monthlyFee: number; + /** ISO date the next invoice closes. */ + nextBillingDate: string; + /** Optional user-set hard spend cap for the month, in USD. */ + spendCap: number | null; + /** True when the free plan's doc cap has been reached. */ + capReached: boolean; +} + +/** Builds a deterministic billing summary for a tier from the usage series. */ +export function buildBillingSummary(tier: Tier): BillingSummary { + const docs30d = buildUsageSeries().reduce((sum, p) => sum + p.value, 0); + const included = TIER_DOC_CAP[tier]; + const nextBillingDate = nextMonthFirst(); + + if (tier === "free") { + // Free is gated, not metered — show progress toward the hard cap. 463/500 + // sits in the "approaching cap" band that drives the upgrade nudge. + const docs = 463; + return { + tier, + planName: "Free", + docsThisPeriod: docs, + includedDocs: included, + costThisMonth: 0, + overageDocs: 0, + overageCost: 0, + overageRate: OVERAGE_RATE, + monthlyFee: 0, + nextBillingDate, + spendCap: null, + capReached: docs >= included, + }; + } + + if (tier === "enterprise") { + // Committed-volume contract — usage sits comfortably inside the commit. + return { + tier, + planName: "Enterprise (committed)", + docsThisPeriod: docs30d, + includedDocs: included, + costThisMonth: 18_000, + overageDocs: 0, + overageCost: 0, + overageRate: 0.018, + monthlyFee: 18_000, + nextBillingDate, + spendCap: null, + capReached: false, + }; + } + + // Pro: pay-as-you-go with a small platform fee + metered overage. + const monthlyFee = 49; + const overageDocs = Math.max(0, docs30d - included); + const overageCost = +(overageDocs * OVERAGE_RATE).toFixed(2); + return { + tier, + planName: "Pay-as-you-go", + docsThisPeriod: docs30d, + includedDocs: included, + costThisMonth: +(monthlyFee + overageCost).toFixed(2), + overageDocs, + overageCost, + overageRate: OVERAGE_RATE, + monthlyFee, + nextBillingDate, + spendCap: 2_500, + capReached: false, + }; +} + +/** First day of next month, ISO (YYYY-MM-DD) — the next invoice close date. */ +function nextMonthFirst(): string { + const now = new Date(); + const next = new Date(now.getFullYear(), now.getMonth() + 1, 1); + return next.toISOString().slice(0, 10); +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Plan catalogue (current + available plan cards) */ +/* ──────────────────────────────────────────────────────────────────────── */ + +export interface PlanOption { + tier: Tier; + name: string; + /** Headline price line, e.g. "$0" or "$0.05 / doc". */ + price: string; + /** Cadence sub-line under the price, e.g. "forever" or "+ $49/mo platform". */ + priceCadence: string; + /** One-line positioning blurb. */ + blurb: string; + /** Bullet feature list. */ + features: string[]; +} + +export const PLAN_OPTIONS: PlanOption[] = [ + { + tier: "free", + name: "Free", + price: "$0", + priceCadence: "forever", + blurb: "Kick the tyres on a single project.", + features: [ + "500 docs / month", + "All single operations", + "1 pipeline · 1 agent", + "Community support", + ], + }, + { + tier: "pro", + name: "Pay-as-you-go", + price: "$0.05", + priceCadence: "per doc · + $49/mo platform", + blurb: "Scale with usage, only pay for what you process.", + features: [ + "25,000 docs included", + "Unlimited pipelines & agents", + "Overage at $0.05 / doc", + "Email support · 99.9% SLA", + ], + }, + { + tier: "enterprise", + name: "Enterprise", + price: "Custom", + priceCadence: "committed annual volume", + blurb: "Committed volume, bespoke terms, dedicated regions.", + features: [ + "Committed-volume pricing", + "Dedicated & on-prem regions", + "SSO · audit log export · DPA", + "Named CSM · 99.99% SLA", + ], + }, +]; + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Billing history table */ +/* ──────────────────────────────────────────────────────────────────────── */ + +export type InvoiceStatus = "paid" | "due" | "pending" | "refunded"; + +export interface BillingHistoryRow { + id: string; + /** ISO date the line item posted. */ + date: string; + description: string; + /** Docs attributed to the line item (0 for flat fees / credits). */ + docs: number; + /** Amount in USD; negative for credits / refunds. */ + amount: number; + status: InvoiceStatus; +} + +export function buildBillingHistory(tier: Tier): BillingHistoryRow[] { + if (tier === "free") { + // Free has no charges — just the running tally line. + return [ + { + id: "bh-free-1", + date: monthsAgo(0), + description: "Free plan usage · 463 / 500 docs", + docs: 463, + amount: 0, + status: "pending", + }, + { + id: "bh-free-2", + date: monthsAgo(1), + description: "Free plan usage · 500 / 500 docs (capped)", + docs: 500, + amount: 0, + status: "paid", + }, + ]; + } + + if (tier === "enterprise") { + return [ + { + id: "bh-ent-1", + date: monthsAgo(0), + description: "Committed volume · annual contract (monthly draw)", + docs: 1_240_511, + amount: 18_000, + status: "pending", + }, + { + id: "bh-ent-2", + date: monthsAgo(1), + description: "Committed volume · annual contract (monthly draw)", + docs: 1_188_204, + amount: 18_000, + status: "paid", + }, + { + id: "bh-ent-3", + date: monthsAgo(1), + description: "Dedicated region · ap-southeast-1 provisioning", + docs: 0, + amount: 4_500, + status: "paid", + }, + { + id: "bh-ent-4", + date: monthsAgo(2), + description: "Committed volume · annual contract (monthly draw)", + docs: 1_092_876, + amount: 18_000, + status: "paid", + }, + { + id: "bh-ent-5", + date: monthsAgo(3), + description: "Overage credit · region migration goodwill", + docs: 0, + amount: -1_200, + status: "refunded", + }, + ]; + } + + // Pro: platform fee + metered overage each cycle. + const docs30d = buildUsageSeries().reduce((sum, p) => sum + p.value, 0); + const overage = Math.max(0, docs30d - TIER_DOC_CAP.pro); + return [ + { + id: "bh-pro-1", + date: monthsAgo(0), + description: "Platform fee · current cycle", + docs: 0, + amount: 49, + status: "due", + }, + { + id: "bh-pro-2", + date: monthsAgo(0), + description: `Document overage · ${overage.toLocaleString()} docs @ $0.05`, + docs: overage, + amount: +(overage * OVERAGE_RATE).toFixed(2), + status: "due", + }, + { + id: "bh-pro-3", + date: monthsAgo(1), + description: "Platform fee · last cycle", + docs: 0, + amount: 49, + status: "paid", + }, + { + id: "bh-pro-4", + date: monthsAgo(1), + description: "Document overage · 31,402 docs @ $0.05", + docs: 31_402, + amount: 1_570.1, + status: "paid", + }, + { + id: "bh-pro-5", + date: monthsAgo(2), + description: "Platform fee · prior cycle", + docs: 0, + amount: 49, + status: "paid", + }, + { + id: "bh-pro-6", + date: monthsAgo(2), + description: "Document overage · 18,945 docs @ $0.05", + docs: 18_945, + amount: 947.25, + status: "paid", + }, + ]; +} + +/** N whole months back from today, on the 1st, ISO (YYYY-MM-DD). */ +function monthsAgo(n: number): string { + const now = new Date(); + const d = new Date(now.getFullYear(), now.getMonth() - n, 1); + return d.toISOString().slice(0, 10); +} + +/** Full usage payload for the 30-day chart — the same series Home charts. */ +export function buildUsagePayload(): UsageSeriesResponse { + return buildUsageSeriesResponse(); +} diff --git a/frontend/portal/src/views/DeveloperDocs.css b/frontend/portal/src/views/DeveloperDocs.css new file mode 100644 index 000000000..e023afeb6 --- /dev/null +++ b/frontend/portal/src/views/DeveloperDocs.css @@ -0,0 +1,525 @@ +.portal-docs { + display: grid; + grid-template-columns: 15rem minmax(0, 1fr); + gap: 2rem; + padding: 1.5rem; + max-width: 84rem; + margin: 0 auto; + align-items: start; +} + +@media (max-width: 60rem) { + .portal-docs { + grid-template-columns: 1fr; + gap: 1.25rem; + } +} + +/* ── Left nav ──────────────────────────────────────────────────────────── */ + +.portal-docs__sidebar { + position: sticky; + top: 1.5rem; + align-self: start; +} + +@media (max-width: 60rem) { + .portal-docs__sidebar { + position: static; + } +} + +.portal-docs__nav { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.portal-docs__nav-group { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.portal-docs__nav-head { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-text-4); + padding: 0 0.5rem; +} + +.portal-docs__nav-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.125rem; + height: 1.125rem; + font-size: 0.6875rem; + border-radius: var(--radius-sm); + background: var(--color-bg-subtle); + color: var(--color-text-3); +} + +.portal-docs__nav-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.0625rem; +} + +.portal-docs__nav-link { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + width: 100%; + padding: 0.375rem 0.5rem; + border: none; + background: transparent; + border-radius: var(--radius-md); + font-size: 0.8125rem; + color: var(--color-text-3); + cursor: pointer; + text-align: left; + transition: + background var(--motion-fast), + color var(--motion-fast); +} + +.portal-docs__nav-link:hover { + background: var(--color-bg-hover); + color: var(--color-text-1); +} + +.portal-docs__nav-link.is-active { + background: var(--color-blue-light); + color: var(--color-blue); + font-weight: 600; +} + +.portal-docs__nav-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ── Content shell ─────────────────────────────────────────────────────── */ + +.portal-docs__content { + min-width: 0; +} + +.portal-docs__section { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.portal-docs__section-eyebrow { + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.08em; + color: var(--color-blue); +} + +.portal-docs__section-title { + margin: 0; + font-size: 1.75rem; + font-weight: 700; + line-height: 1.15; + color: var(--color-text-1); +} + +.portal-docs__section-lead { + margin: 0; + max-width: 46rem; + font-size: 0.9375rem; + line-height: 1.6; + color: var(--color-text-3); +} + +.portal-docs__callout { + font-size: 0.875rem; + line-height: 1.55; + color: var(--color-text-2); +} + +.portal-docs__callout code { + font-family: var(--font-mono, monospace); + font-size: 0.8125rem; + background: var(--color-bg-subtle); + padding: 0.0625rem 0.3125rem; + border-radius: var(--radius-sm); +} + +/* ── Quickstart steps ──────────────────────────────────────────────────── */ + +.portal-docs__steps { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.portal-docs__step { + display: grid; + grid-template-columns: 1.75rem minmax(0, 1fr); + gap: 1rem; +} + +.portal-docs__step-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: var(--radius-pill); + background: var(--color-blue); + color: var(--color-text-on-accent); + font-size: 0.8125rem; + font-weight: 700; +} + +.portal-docs__step-body { + display: flex; + flex-direction: column; + gap: 0.625rem; + min-width: 0; +} + +.portal-docs__step-body h3 { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-docs__step-body p { + margin: 0; + font-size: 0.875rem; + line-height: 1.55; + color: var(--color-text-3); +} + +.portal-docs__snippet { + display: flex; + flex-direction: column; + gap: 0.625rem; +} + +/* ── Auth ──────────────────────────────────────────────────────────────── */ + +.portal-docs__keytable { + display: flex; + flex-direction: column; + gap: 0.625rem; +} + +.portal-docs__keyrow { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 0.875rem; + color: var(--color-text-3); +} + +/* ── Rate limits ───────────────────────────────────────────────────────── */ + +.portal-docs__limits { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.75rem; +} + +@media (max-width: 40rem) { + .portal-docs__limits { + grid-template-columns: 1fr; + } +} + +.portal-docs__limit-label { + font-size: 0.75rem; + color: var(--color-text-4); + margin-bottom: 0.25rem; +} + +.portal-docs__limit-value { + font-size: 1.25rem; + font-weight: 700; + color: var(--color-text-1); +} + +/* ── Endpoint reference ────────────────────────────────────────────────── */ + +.portal-docs__endpoints { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.portal-docs__endpoint-group { + display: flex; + flex-direction: column; + border: 1px solid var(--color-border-light); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.portal-docs__endpoint-grouphead { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 0.875rem; + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-1); + background: var(--color-bg-subtle); + border-bottom: 1px solid var(--color-border-light); +} + +.portal-docs__endpoint-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; +} + +.portal-docs__endpoint-row { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.875rem; + border-top: 1px solid var(--color-border-light); + font-size: 0.8125rem; +} + +.portal-docs__endpoint-row:first-of-type { + border-top: none; +} + +.portal-docs__endpoint-path { + font-family: var(--font-mono, monospace); + font-size: 0.8125rem; + color: var(--color-text-1); + min-width: 11rem; +} + +.portal-docs__endpoint-name { + color: var(--color-text-3); + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.portal-docs__endpoint-fields { + font-size: 0.75rem; + color: var(--color-text-4); + white-space: nowrap; +} + +@media (max-width: 40rem) { + .portal-docs__endpoint-name { + display: none; + } +} + +/* ── Errors ────────────────────────────────────────────────────────────── */ + +.portal-docs__errors { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.portal-docs__error-row { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 0.875rem; + color: var(--color-text-3); +} + +/* ── SDK grid ──────────────────────────────────────────────────────────── */ + +.portal-docs__sdk-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.875rem; +} + +@media (max-width: 48rem) { + .portal-docs__sdk-grid { + grid-template-columns: 1fr; + } +} + +.portal-docs__sdk-head { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.625rem; +} + +.portal-docs__sdk-icon { + font-size: 1.125rem; +} + +.portal-docs__sdk-name { + margin: 0; + font-size: 0.9375rem; + font-weight: 600; + color: var(--color-text-1); + flex: 1; +} + +/* ── Components ────────────────────────────────────────────────────────── */ + +.portal-docs__component-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.875rem; +} + +@media (max-width: 48rem) { + .portal-docs__component-grid { + grid-template-columns: 1fr; + } +} + +.portal-docs__component-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.portal-docs__component-name { + font-family: var(--font-mono, monospace); + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-purple); +} + +.portal-docs__component-blurb { + margin: 0; + font-size: 0.8125rem; + line-height: 1.5; + color: var(--color-text-3); +} + +/* ── Playbooks ─────────────────────────────────────────────────────────── */ + +.portal-docs__playbook-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.875rem; +} + +@media (max-width: 48rem) { + .portal-docs__playbook-grid { + grid-template-columns: 1fr; + } +} + +.portal-docs__playbook-title { + margin: 0 0 0.375rem; + font-size: 1rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-docs__playbook-blurb { + margin: 0 0 0.875rem; + font-size: 0.8125rem; + line-height: 1.5; + color: var(--color-text-3); +} + +.portal-docs__playbook-flow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem; + margin-bottom: 0.875rem; +} + +.portal-docs__playbook-step { + display: inline-flex; + align-items: center; + gap: 0.375rem; +} + +.portal-docs__playbook-arrow { + color: var(--color-text-4); + font-size: 0.75rem; +} + +/* ── Skills ────────────────────────────────────────────────────────────── */ + +.portal-docs__skill-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.875rem; +} + +@media (max-width: 60rem) { + .portal-docs__skill-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 40rem) { + .portal-docs__skill-grid { + grid-template-columns: 1fr; + } +} + +.portal-docs__skill-head { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.portal-docs__skill-glyph { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border-radius: var(--radius-sm); + background: var(--color-purple-light); + color: var(--color-purple); + font-size: 0.8125rem; +} + +.portal-docs__skill-name { + margin: 0; + font-size: 0.9375rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-docs__skill-blurb { + margin: 0 0 0.625rem; + font-size: 0.8125rem; + line-height: 1.5; + color: var(--color-text-3); +} + +.portal-docs__skill-ops { + font-family: var(--font-mono, monospace); + font-size: 0.75rem; + color: var(--color-text-4); +} diff --git a/frontend/portal/src/views/DeveloperDocs.tsx b/frontend/portal/src/views/DeveloperDocs.tsx new file mode 100644 index 000000000..e7758a944 --- /dev/null +++ b/frontend/portal/src/views/DeveloperDocs.tsx @@ -0,0 +1,95 @@ +import { useState } from "react"; +import { EmptyState } from "@shared/components"; +import { useTier } from "@portal/contexts/TierContext"; +import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; +import { + fetchDocsContent, + fetchDocsNav, + type DocsContent, + type DocsNavSection, +} from "@portal/api/docs"; +import { DocsNav, DocsNavSkeleton } from "@portal/components/docs/DocsNav"; +import { GettingStartedSection } from "@portal/components/docs/GettingStartedSection"; +import { AuthenticationSection } from "@portal/components/docs/AuthenticationSection"; +import { RateLimitsSection } from "@portal/components/docs/RateLimitsSection"; +import { EndpointReferenceSection } from "@portal/components/docs/EndpointReferenceSection"; +import { ErrorsSection } from "@portal/components/docs/ErrorsSection"; +import { WebhooksSection } from "@portal/components/docs/WebhooksSection"; +import { SdksSection } from "@portal/components/docs/SdksSection"; +import { ComponentsSection } from "@portal/components/docs/ComponentsSection"; +import { PlaybooksSection } from "@portal/components/docs/PlaybooksSection"; +import { SkillsSection } from "@portal/components/docs/SkillsSection"; +import "@portal/views/DeveloperDocs.css"; + +/** Renders the content pane for the active nav leaf against fetched content. */ +function DocsContentPane({ + active, + content, +}: { + active: string; + content: DocsContent; +}) { + switch (active) { + case "authentication": + return ; + case "rate-limits": + return ; + case "endpoints": + return ; + case "errors": + return ; + case "webhooks": + return ; + case "sdk-overview": + return ; + case "component-library": + return ; + case "recipes": + return ; + case "skill-catalog": + return ; + default: + return ( + + ); + } +} + +export function DeveloperDocs() { + const { tier } = useTier(); + const [active, setActive] = useState("quickstart"); + + const navState = useAsync(() => fetchDocsNav(), []); + const { data: nav } = navState; + const { isLoading, isEmpty } = useSectionFlags(navState); + + const { data: content } = useAsync( + () => fetchDocsContent(tier), + [tier], + ); + + return ( +
    + + +
    + {content && } +
    +
    + ); +} diff --git a/frontend/portal/src/views/Home.css b/frontend/portal/src/views/Home.css index 0f7b435aa..c2f8bf6f2 100644 --- a/frontend/portal/src/views/Home.css +++ b/frontend/portal/src/views/Home.css @@ -42,18 +42,7 @@ color: var(--color-text-3); } -/* Metric strip */ -.portal-home__metrics { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 0.75rem; -} - -@media (max-width: 50rem) { - .portal-home__metrics { - grid-template-columns: repeat(2, 1fr); - } -} +/* Metric strip now uses the shared . */ /* Two-column dashboard row (activity + quick actions) */ .portal-home__row { diff --git a/frontend/portal/src/views/Home.tsx b/frontend/portal/src/views/Home.tsx index 9ccf7ea08..d3da4ee07 100644 --- a/frontend/portal/src/views/Home.tsx +++ b/frontend/portal/src/views/Home.tsx @@ -4,6 +4,7 @@ import { Card, EmptyState, MetricCard, + MetricStrip, Skeleton, StatusBadge, } from "@shared/components"; @@ -365,7 +366,7 @@ function TierKpiStrip() { ); return ( -
    + {labels.map((spec, i) => { // useAsync keeps the previous tier's data during a refetch; ignore it // while loading so the new labels never pair with stale values. @@ -381,7 +382,7 @@ function TierKpiStrip() { /> ); })} -
    +
    ); } diff --git a/frontend/portal/src/views/Infrastructure.css b/frontend/portal/src/views/Infrastructure.css new file mode 100644 index 000000000..0e9c7c7f5 --- /dev/null +++ b/frontend/portal/src/views/Infrastructure.css @@ -0,0 +1,428 @@ +.portal-infra { + display: flex; + flex-direction: column; + gap: 1.25rem; + padding: 1.5rem; + max-width: 84rem; + margin: 0 auto; +} + +/* View header */ +.portal-infra__head { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.portal-infra__title { + margin: 0; + font-size: 1.375rem; + font-weight: 650; + color: var(--color-text-1); +} + +.portal-infra__sub { + margin: 0; + font-size: 0.8125rem; + color: var(--color-text-4); + max-width: 48rem; +} + +.portal-infra__panel { + display: flex; + flex-direction: column; +} + +/* Generic vertical stack used inside every tab */ +.portal-infra__stack { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.portal-infra__bar { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 1rem; +} + +/* Section headers */ +.portal-infra__section-head { + margin-bottom: 0.625rem; +} + +.portal-infra__section-title { + margin: 0; + font-size: 0.9375rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-infra__section-sub { + margin: 0.125rem 0 0; + font-size: 0.75rem; + color: var(--color-text-4); +} + +/* Two-column split */ +.portal-infra__split { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + align-items: start; +} + +@media (max-width: 60rem) { + .portal-infra__split { + grid-template-columns: 1fr; + } +} + +/* Shared cell styles */ +.portal-infra__cell-stack { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.portal-infra__cell-strong { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-infra__cell-code { + font-family: var(--font-mono); + font-size: 0.6875rem; + color: var(--color-text-3); + background: var(--color-bg-code); + padding: 0.0625rem 0.3125rem; + border-radius: var(--radius-sm); + width: fit-content; +} + +.portal-infra__mono { + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--color-text-2); +} + +.portal-infra__muted { + font-size: 0.75rem; + color: var(--color-text-4); +} + +/* Region load cell */ +.portal-infra__load { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.portal-infra__load-pct { + font-family: var(--font-mono); + font-size: 0.6875rem; + color: var(--color-text-3); + min-width: 2.25rem; + text-align: right; +} + +/* Metric strip (audit) */ +.portal-infra__metrics { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.75rem; +} + +@media (max-width: 50rem) { + .portal-infra__metrics { + grid-template-columns: repeat(2, 1fr); + } +} + +/* ── API keys ───────────────────────────────────────────────────────────── */ +.portal-infra__keys { + display: flex; + flex-direction: column; + gap: 0.625rem; +} + +.portal-infra__key { + padding: 0 !important; + overflow: hidden; +} + +.portal-infra__key-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + width: 100%; + padding: 0.875rem 1.125rem; + background: transparent; + text-align: left; + transition: background var(--motion-fast); +} + +.portal-infra__key-head:hover { + background: var(--color-bg-hover); +} + +.portal-infra__key-id { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.portal-infra__key-head-right { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.portal-infra__chevron { + font-size: 1.125rem; + color: var(--color-text-5); + transition: transform var(--motion-fast); + line-height: 1; +} + +.portal-infra__chevron.is-open { + transform: rotate(90deg); +} + +.portal-infra__key-body { + padding: 0 1.125rem 1.125rem; + border-top: 1px solid var(--color-border-light); +} + +.portal-infra__kv { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.875rem 1.25rem; + margin: 1rem 0 0; +} + +@media (max-width: 48rem) { + .portal-infra__kv { + grid-template-columns: repeat(2, 1fr); + } +} + +.portal-infra__kv-wide { + grid-column: 1 / -1; +} + +.portal-infra__kv dt { + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-5); + margin-bottom: 0.25rem; +} + +.portal-infra__kv dd { + margin: 0; + font-size: 0.8125rem; + color: var(--color-text-2); +} + +.portal-infra__chips { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +/* Modal form */ +.portal-infra__form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.portal-infra__perm-row { + display: flex; + gap: 1.25rem; + flex-wrap: wrap; +} + +.portal-infra__modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.625rem; +} + +/* ── Security ───────────────────────────────────────────────────────────── */ +.portal-infra__banner { + margin-top: 1rem; +} + +.portal-infra__certs { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.75rem; +} + +@media (max-width: 60rem) { + .portal-infra__certs { + grid-template-columns: repeat(2, 1fr); + } +} + +.portal-infra__cert-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.375rem; +} + +.portal-infra__cert-detail { + margin: 0; + font-size: 0.6875rem; + color: var(--color-text-4); + line-height: 1.4; +} + +/* ── Storage ────────────────────────────────────────────────────────────── */ +.portal-infra__usage-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.625rem; +} + +.portal-infra__usage-value { + font-size: 1.25rem; + font-weight: 650; + color: var(--color-text-1); +} + +.portal-infra__usage-value .portal-infra__muted { + font-size: 0.875rem; + font-weight: 400; +} + +.portal-infra__providers { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.portal-infra__provider { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 0.75rem; + padding: 0.625rem 0.75rem; + background: var(--color-bg-subtle); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); +} + +.portal-infra__provider-glyph { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: var(--radius-md); + background: var(--color-blue-light); + color: var(--color-blue); + font-size: 0.875rem; +} + +.portal-infra__provider-text { + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; +} + +.portal-infra__provider-meta { + display: flex; + align-items: center; + gap: 0.625rem; +} + +/* Lifecycle timeline */ +.portal-infra__lifecycle { + display: flex; + align-items: stretch; + gap: 0.5rem; + margin-top: 1rem; +} + +.portal-infra__lifecycle-stage { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.625rem 0.75rem; + background: var(--color-bg-subtle); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); +} + +.portal-infra__lifecycle-stage.is-active { + background: var(--color-green-light); + border-color: var(--color-green-border); +} + +.portal-infra__lifecycle-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--color-text-5); +} + +.portal-infra__lifecycle-stage.is-active .portal-infra__lifecycle-dot { + background: var(--color-green); +} + +.portal-infra__lifecycle-label { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-infra__lifecycle-arrow { + display: flex; + align-items: center; + color: var(--color-text-5); + font-size: 0.875rem; +} + +/* ── Audit ──────────────────────────────────────────────────────────────── */ +.portal-infra__event { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.portal-infra__event span { + font-size: 0.8125rem; + color: var(--color-text-2); +} + +/* ── Table skeleton ─────────────────────────────────────────────────────── */ +.portal-infra__table-skel { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1rem 1.125rem; +} + +.portal-infra__table-skel-row { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; + gap: 1rem; + align-items: center; +} diff --git a/frontend/portal/src/views/Infrastructure.tsx b/frontend/portal/src/views/Infrastructure.tsx new file mode 100644 index 000000000..a9712d30b --- /dev/null +++ b/frontend/portal/src/views/Infrastructure.tsx @@ -0,0 +1,50 @@ +import { useState } from "react"; +import { Tabs, type TabItem } from "@shared/components"; +import { DeploymentsTab } from "@portal/components/infrastructure/DeploymentsTab"; +import { ApiKeysTab } from "@portal/components/infrastructure/ApiKeysTab"; +import { SecurityTab } from "@portal/components/infrastructure/SecurityTab"; +import { StorageTab } from "@portal/components/infrastructure/StorageTab"; +import { AuditTab } from "@portal/components/infrastructure/AuditTab"; +import "@portal/views/Infrastructure.css"; + +type InfraTab = "deployments" | "api-keys" | "security" | "storage" | "audit"; + +const TABS: TabItem[] = [ + { key: "deployments", label: "Deployments" }, + { key: "api-keys", label: "API Keys" }, + { key: "security", label: "Security" }, + { key: "storage", label: "Storage" }, + { key: "audit", label: "Audit Logs" }, +]; + +export function Infrastructure() { + const [tab, setTab] = useState("deployments"); + + return ( +
    +
    +

    Infrastructure

    +

    + Deployments, credentials, security posture, storage, and the audit + trail for your Stirling workspace. +

    +
    + + + items={TABS} + activeKey={tab} + onChange={setTab} + variant="underline" + ariaLabel="Infrastructure sections" + /> + +
    + {tab === "deployments" && } + {tab === "api-keys" && } + {tab === "security" && } + {tab === "storage" && } + {tab === "audit" && } +
    +
    + ); +} diff --git a/frontend/portal/src/views/Pipelines.css b/frontend/portal/src/views/Pipelines.css new file mode 100644 index 000000000..645ac144b --- /dev/null +++ b/frontend/portal/src/views/Pipelines.css @@ -0,0 +1,518 @@ +.portal-pipelines { + display: flex; + flex-direction: column; + gap: 1.25rem; + padding: 1.5rem; + max-width: 84rem; + margin: 0 auto; +} + +/* Header */ +.portal-pipelines__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.portal-pipelines__title { + margin: 0; + font-size: 1.375rem; + font-weight: 650; + color: var(--color-text-1); +} + +.portal-pipelines__sub { + margin: 0.25rem 0 0; + font-size: 0.8125rem; + line-height: 1.5; + color: var(--color-text-3); + max-width: 46rem; +} + +/* Fleet summary chips */ +.portal-pipelines__fleet { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.portal-pipelines__fleet-count { + font-size: 0.75rem; + color: var(--color-text-4); +} + +/* List */ +.portal-pipelines__list { + display: flex; + flex-direction: column; + gap: 0.875rem; +} + +.portal-pipelines__card { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.portal-pipelines__card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; +} + +.portal-pipelines__card-title { + margin: 0; + font-size: 1.0625rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-pipelines__card-blurb { + margin: 0.125rem 0 0; + font-size: 0.8125rem; + color: var(--color-text-3); +} + +/* Source → stages → destination rail */ +.portal-pipelines__card-rail { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.portal-pipelines__rail-chip { + font-size: 0.6875rem; + font-weight: 500; + color: var(--color-text-3); + background: var(--color-bg-subtle); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-pill); + padding: 0.1875rem 0.5rem; +} + +.portal-pipelines__rail-arrow { + color: var(--color-text-5); + font-size: 0.75rem; +} + +.portal-pipelines__stage-dots { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.portal-pipelines__stage-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; +} + +/* Metric strip on the card */ +.portal-pipelines__metrics { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 0.5rem; + padding: 0.625rem 0; + border-top: 1px solid var(--color-border-light); + border-bottom: 1px solid var(--color-border-light); +} + +@media (max-width: 50rem) { + .portal-pipelines__metrics { + grid-template-columns: repeat(2, 1fr); + } +} + +.portal-pipelines__card-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + font-size: 0.6875rem; + color: var(--color-text-4); +} + +.portal-pipelines__card-version { + font-family: var(--font-mono); +} + +.portal-pipelines__card-golden { + color: var(--color-text-3); +} + +.portal-pipelines__card-drift { + color: var(--color-amber-dark); +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Detail drawer */ +/* ──────────────────────────────────────────────────────────────────────── */ + +.portal-pipelines__detail { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.portal-pipelines__detail-metrics { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.625rem; + padding: 0.875rem; + background: var(--color-bg-subtle); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); +} + +.portal-pipelines__detail-section { + display: flex; + flex-direction: column; + gap: 0.625rem; +} + +.portal-pipelines__detail-h { + margin: 0; + font-size: 0.9375rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-pipelines__detail-sub { + margin: -0.375rem 0 0; + font-size: 0.75rem; + color: var(--color-text-4); +} + +/* The five silent stages */ +.portal-pipelines__stages { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.portal-pipelines__stage { + padding: 0.625rem 0.75rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); +} + +.portal-pipelines__stage-head { + display: flex; + align-items: center; + gap: 0.4375rem; + margin-bottom: 0.5rem; +} + +.portal-pipelines__stage-pip { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; +} + +.portal-pipelines__stage-name { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-2); +} + +.portal-pipelines__stage-chips { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.portal-pipelines__stage-empty { + font-size: 0.75rem; + color: var(--color-text-5); + font-style: italic; +} + +/* Golden set */ +.portal-pipelines__golden { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem; + background: var(--color-bg-subtle); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); +} + +.portal-pipelines__golden-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.portal-pipelines__golden-when { + font-size: 0.6875rem; + color: var(--color-text-4); +} + +/* Schema drift */ +.portal-pipelines__drift-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.portal-pipelines__drift { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 0.625rem; + padding: 0.625rem 0.75rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); +} + +.portal-pipelines__drift-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; +} + +.portal-pipelines__drift-text { + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; +} + +.portal-pipelines__drift-field { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-1); + font-family: var(--font-mono); +} + +.portal-pipelines__drift-note { + font-size: 0.75rem; + color: var(--color-text-4); +} + +.portal-pipelines__drift-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.125rem; + font-size: 0.6875rem; + color: var(--color-text-4); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Composer modal */ +/* ──────────────────────────────────────────────────────────────────────── */ + +.portal-pipelines__composer { + min-height: 22rem; +} + +.portal-pipelines__composer-body { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.portal-pipelines__composer-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.625rem; +} + +@media (max-width: 44rem) { + .portal-pipelines__composer-grid { + grid-template-columns: 1fr; + } +} + +.portal-pipelines__option { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.75rem; + text-align: left; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + cursor: pointer; + transition: + border-color var(--motion-fast), + background var(--motion-fast); +} + +.portal-pipelines__option:hover { + border-color: var(--color-border-hover); + background: var(--color-bg-hover); +} + +.portal-pipelines__option.is-selected { + border-color: var(--color-blue); + background: var(--color-blue-light); +} + +.portal-pipelines__option-label { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-pipelines__option-desc { + font-size: 0.6875rem; + color: var(--color-text-4); + line-height: 1.4; +} + +/* Op chain summary */ +.portal-pipelines__chain { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem; + background: var(--color-bg-subtle); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); +} + +.portal-pipelines__chain-label, +.portal-pipelines__agents-label { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-3); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.portal-pipelines__chain-chips, +.portal-pipelines__agents-row { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.portal-pipelines__chain-empty { + font-size: 0.75rem; + color: var(--color-text-5); + font-style: italic; +} + +.portal-pipelines__agents { + display: flex; + flex-direction: column; + gap: 0.4375rem; +} + +/* Op library picker */ +.portal-pipelines__library { + display: flex; + flex-direction: column; + gap: 0.875rem; + max-height: 19rem; + overflow-y: auto; + padding: 0.75rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); +} + +.portal-pipelines__library-group { + display: flex; + flex-direction: column; + gap: 0.4375rem; +} + +.portal-pipelines__library-head { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-2); +} + +.portal-pipelines__library-pip { + width: 0.4375rem; + height: 0.4375rem; + border-radius: 50%; +} + +.portal-pipelines__library-chips { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +/* Routing alerts */ +.portal-pipelines__alerts { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.portal-pipelines__alert { + display: grid; + grid-template-columns: auto 1fr; + align-items: start; + gap: 0.625rem; + padding: 0.625rem 0.75rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + cursor: pointer; +} + +.portal-pipelines__alert input { + margin-top: 0.1875rem; + accent-color: var(--color-blue); +} + +.portal-pipelines__alert > span { + display: flex; + flex-direction: column; + gap: 0.0625rem; +} + +.portal-pipelines__alert strong { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-pipelines__alert span span { + font-size: 0.6875rem; + color: var(--color-text-4); +} + +/* Composer step indicator in the footer */ +.portal-pipelines__composer-steps { + display: flex; + align-items: center; + gap: 0.625rem; + margin-right: auto; +} + +.portal-pipelines__composer-step { + font-size: 0.6875rem; + font-weight: 500; + color: var(--color-text-5); +} + +.portal-pipelines__composer-step.is-active { + color: var(--color-blue); + font-weight: 600; +} + +.portal-pipelines__composer-step.is-done { + color: var(--color-green-dark); +} diff --git a/frontend/portal/src/views/Pipelines.tsx b/frontend/portal/src/views/Pipelines.tsx new file mode 100644 index 000000000..f4297045a --- /dev/null +++ b/frontend/portal/src/views/Pipelines.tsx @@ -0,0 +1,125 @@ +import { useMemo, useState } from "react"; +import { + Banner, + Button, + Drawer, + EmptyState, + StatusBadge, +} from "@shared/components"; +import { useTier } from "@portal/contexts/TierContext"; +import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; +import { + fetchPipelines, + type Pipeline, + type PipelinesResponse, +} from "@portal/api/pipelines"; +import { PipelineCard } from "@portal/components/pipelines/PipelineCard"; +import { PipelineComposer } from "@portal/components/pipelines/PipelineComposer"; +import { PipelineDetail } from "@portal/components/pipelines/PipelineDetail"; +import { PipelineListSkeleton } from "@portal/components/pipelines/PipelineListSkeleton"; +import "@portal/views/Pipelines.css"; + +export function Pipelines() { + const { tier } = useTier(); + const state = useAsync(() => fetchPipelines(tier), [tier]); + const { data } = state; + const { isLoading } = useSectionFlags(state); + + const [composerOpen, setComposerOpen] = useState(false); + const [selected, setSelected] = useState(null); + + const pipelines = data?.pipelines ?? []; + const evals = data?.evals ?? null; + const isEmpty = !isLoading && pipelines.length === 0; + + const fleetHealthy = useMemo( + () => pipelines.filter((p) => p.status === "healthy").length, + [pipelines], + ); + + return ( +
    +
    +
    +

    Pipelines

    +

    + Document workflows composed from typed operations — deployed, + versioned, and continuously validated against a golden set. +

    +
    + +
    + + {!isLoading && pipelines.length > 0 && ( +
    + + {fleetHealthy} healthy + + {fleetHealthy < pipelines.length && ( + + {pipelines.length - fleetHealthy} degraded + + )} + + {pipelines.length} deployed + +
    + )} + + {tier === "enterprise" && evals && ( + + {evals.shadowCount} pipeline{evals.shadowCount === 1 ? "" : "s"}{" "} + running a shadow eval, {evals.comparativeCount} in a comparative run.{" "} + {evals.detail} + + )} + + {isLoading && } + + {isEmpty && ( + setComposerOpen(true)}> + Build your first pipeline + + } + /> + )} + + {pipelines.length > 0 && ( +
    + {pipelines.map((p) => ( + + ))} +
    + )} + + setSelected(null)} + width="lg" + title={selected?.name} + subtitle={ + selected + ? `${selected.version} · ${selected.source} → ${selected.destination}` + : undefined + } + > + {selected && } + + + setComposerOpen(false)} + /> +
    + ); +} diff --git a/frontend/portal/src/views/Sources.css b/frontend/portal/src/views/Sources.css new file mode 100644 index 000000000..4849c0485 --- /dev/null +++ b/frontend/portal/src/views/Sources.css @@ -0,0 +1,427 @@ +.portal-sources { + display: flex; + flex-direction: column; + gap: 1.25rem; + padding: 1.5rem; + max-width: 84rem; + margin: 0 auto; +} + +/* Header */ +.portal-sources__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.portal-sources__title { + margin: 0; + font-size: 1.375rem; + font-weight: 650; + color: var(--color-text-1); +} + +.portal-sources__sub { + margin: 0.25rem 0 0; + font-size: 0.8125rem; + line-height: 1.5; + color: var(--color-text-3); + max-width: 46rem; +} + +/* Metric strip now uses the shared . */ + +/* Table cells */ +.portal-sources__name-cell { + display: flex; + align-items: center; + gap: 0.625rem; + min-width: 0; +} + +.portal-sources__name-text { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.portal-sources__name-text strong { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-sources__type-dot { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.875rem; + height: 1.875rem; + flex-shrink: 0; + border-radius: var(--radius-md); + font-size: 0.875rem; +} + +.portal-sources__type-dot--neutral { + background: var(--color-bg-subtle); + color: var(--color-text-3); +} +.portal-sources__type-dot--blue { + background: var(--color-blue-light); + color: var(--color-blue); +} +.portal-sources__type-dot--purple { + background: var(--color-purple-light); + color: var(--color-purple); +} +.portal-sources__type-dot--green { + background: var(--color-green-light); + color: var(--color-green-dark); +} +.portal-sources__type-dot--amber { + background: var(--color-amber-light); + color: var(--color-amber-dark); +} +.portal-sources__type-dot--red { + background: var(--color-red-light); + color: var(--color-red); +} + +.portal-sources__muted { + font-size: 0.8125rem; + color: var(--color-text-4); +} + +.portal-sources__caret { + display: inline-block; + color: var(--color-text-5); + transition: transform var(--motion-fast); +} + +.portal-sources__caret.is-open { + transform: rotate(90deg); + color: var(--color-blue); +} + +/* Expanded detail panel */ +.portal-sources__expanded { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + padding: 1.25rem; + animation: portal-sources-reveal var(--motion-fast) ease-out; +} + +@keyframes portal-sources-reveal { + from { + opacity: 0; + transform: translateY(-0.25rem); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.portal-sources__expanded-head { + display: flex; + align-items: center; + gap: 0.75rem; + padding-bottom: 0.875rem; + margin-bottom: 1rem; + border-bottom: 1px solid var(--color-border-light); +} + +.portal-sources__expanded-title { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-sources__expanded-sub { + font-size: 0.75rem; + color: var(--color-text-4); +} + +.portal-sources__expanded-close { + margin-left: auto; + width: 1.75rem; + height: 1.75rem; + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text-4); + font-size: 1.125rem; + line-height: 1; + cursor: pointer; + transition: + background var(--motion-fast), + color var(--motion-fast); +} + +.portal-sources__expanded-close:hover { + background: var(--color-bg-hover); + color: var(--color-text-1); +} + +/* Detail body */ +.portal-sources__detail { + display: flex; + flex-direction: column; + gap: 1.125rem; +} + +.portal-sources__stat-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.875rem; +} + +@media (max-width: 56rem) { + .portal-sources__stat-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +/* Stat tiles now use the shared . */ + +.portal-sources__url { + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--color-text-2); + word-break: break-all; +} + +/* Bars */ +.portal-sources__bar-row { + display: flex; + flex-direction: column; + gap: 0.4375rem; +} + +.portal-sources__bar-head { + display: flex; + align-items: baseline; + justify-content: space-between; + font-size: 0.75rem; + color: var(--color-text-4); +} + +.portal-sources__bar-head strong { + font-size: 0.8125rem; + color: var(--color-text-1); + font-family: var(--font-mono); +} + +/* Detail sub-sections */ +.portal-sources__detail-section { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.portal-sources__detail-heading { + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-5); + font-weight: 600; +} + +.portal-sources__chips { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +/* Endpoint / delivery rows */ +.portal-sources__endpoints { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.portal-sources__endpoint { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 0.625rem; + padding: 0.4375rem 0.625rem; + background: var(--color-bg-subtle); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); +} + +.portal-sources__method { + font-family: var(--font-mono); +} + +.portal-sources__endpoint-path { + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--color-text-2); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.portal-sources__endpoint-calls { + font-size: 0.6875rem; + color: var(--color-text-4); + white-space: nowrap; + font-family: var(--font-mono); +} + +.portal-sources__detail-actions { + display: flex; + gap: 0.5rem; + padding-top: 0.25rem; +} + +/* Table skeleton */ +.portal-sources__table-skeleton { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Connect wizard */ +/* ──────────────────────────────────────────────────────────────────────── */ + +.portal-sources__steps { + list-style: none; + display: flex; + gap: 0.5rem; + margin: 0 0 1.25rem; + padding: 0; +} + +.portal-sources__step { + display: flex; + align-items: center; + gap: 0.4375rem; + flex: 1; + font-size: 0.75rem; + color: var(--color-text-4); + padding: 0.5rem 0.625rem; + border-radius: var(--radius-md); + background: var(--color-bg-subtle); + border: 1px solid var(--color-border-light); +} + +.portal-sources__step.is-active { + color: var(--color-text-1); + font-weight: 600; + border-color: color-mix(in srgb, var(--color-blue) 40%, transparent); + background: var(--color-blue-light); +} + +.portal-sources__step.is-done { + color: var(--color-text-3); +} + +.portal-sources__step-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + border-radius: 50%; + font-size: 0.6875rem; + font-weight: 600; + background: var(--color-surface); + border: 1px solid var(--color-border); + color: var(--color-text-4); +} + +.portal-sources__step.is-active .portal-sources__step-mark { + background: var(--color-blue); + border-color: var(--color-blue); + color: var(--color-text-on-accent); +} + +.portal-sources__step.is-done .portal-sources__step-mark { + background: var(--color-green); + border-color: var(--color-green); + color: var(--color-text-on-accent); +} + +.portal-sources__type-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.625rem; +} + +@media (max-width: 40rem) { + .portal-sources__type-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +.portal-sources__type-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 1rem 0.5rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + cursor: pointer; + transition: + border-color var(--motion-fast), + background var(--motion-fast); +} + +.portal-sources__type-card:hover { + border-color: var(--color-border-strong, var(--color-border)); + background: var(--color-bg-hover); +} + +.portal-sources__type-card.is-selected { + border-color: var(--color-blue); + background: var(--color-blue-light); + box-shadow: 0 0 0 1px var(--color-blue) inset; +} + +.portal-sources__type-icon { + font-size: 1.25rem; + color: var(--color-text-2); +} + +.portal-sources__type-name { + font-size: 0.75rem; + font-weight: 500; + color: var(--color-text-1); +} + +.portal-sources__wizard-body { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.portal-sources__wizard-lead { + margin: 0; + font-size: 0.875rem; + line-height: 1.55; + color: var(--color-text-2); +} + +.portal-sources__wizard-note { + margin: 0; + font-size: 0.75rem; + color: var(--color-text-4); +} + +.portal-sources__wizard-footer { + display: flex; + justify-content: space-between; + gap: 0.5rem; + width: 100%; +} diff --git a/frontend/portal/src/views/Sources.tsx b/frontend/portal/src/views/Sources.tsx new file mode 100644 index 000000000..b40016ebf --- /dev/null +++ b/frontend/portal/src/views/Sources.tsx @@ -0,0 +1,82 @@ +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 { fetchSources, type SourcesResponse } from "@portal/api/sources"; +import { KpiStrip } from "@portal/components/sources/KpiStrip"; +import { SourcesTable } from "@portal/components/sources/SourcesTable"; +import { SourceDetailCard } from "@portal/components/sources/SourceDetailCard"; +import { ConnectWizard } from "@portal/components/sources/ConnectWizard"; +import "@portal/views/Sources.css"; + +export function Sources() { + const { tier } = useTier(); + const state = useAsync(() => fetchSources(tier), [tier]); + const { data, loading } = state; + const { isLoading, isEmpty } = useSectionFlags(state); + + const [expandedId, setExpandedId] = useState(null); + const [wizardOpen, setWizardOpen] = useState(false); + + const sources = data?.sources ?? []; + const expanded = sources.find((s) => s.id === expandedId) ?? null; + + return ( +
    +
    +
    +

    Sources & Agents

    +

    + Every place documents flow into Stirling — agents, API clients, + webhooks, connectors and more. Click a row for type-specific detail. +

    +
    + +
    + + + + {isLoading && ( +
    + {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
    + )} + + {isEmpty && ( + setWizardOpen(true)}>Connect source + } + /> + )} + + {!isLoading && !isEmpty && sources.length > 0 && ( + + setExpandedId((cur) => (cur === s.id ? null : s.id)) + } + /> + )} + + {expanded && ( + setExpandedId(null)} + /> + )} + + setWizardOpen(false)} /> +
    + ); +} diff --git a/frontend/portal/src/views/Usage.css b/frontend/portal/src/views/Usage.css new file mode 100644 index 000000000..bf39aeb10 --- /dev/null +++ b/frontend/portal/src/views/Usage.css @@ -0,0 +1,340 @@ +.portal-usage { + display: flex; + flex-direction: column; + gap: 1.25rem; + padding: 1.5rem; + max-width: 84rem; + margin: 0 auto; +} + +/* Header */ +.portal-usage__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.portal-usage__title { + margin: 0; + font-size: 1.375rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-usage__subtitle { + margin: 0.25rem 0 0; + font-size: 0.8125rem; + color: var(--color-text-4); +} + +/* Two-column row: current plan + spend cap */ +.portal-usage__row { + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); + gap: 1rem; + align-items: stretch; +} + +@media (max-width: 60rem) { + .portal-usage__row { + grid-template-columns: 1fr; + } +} + +/* Section headers */ +.portal-usage__section-head { + margin-bottom: 0.875rem; +} + +.portal-usage__section-title { + margin: 0; + font-size: 0.9375rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-usage__section-sub { + margin: 0.25rem 0 0; + font-size: 0.75rem; + color: var(--color-text-4); +} + +/* Current plan card */ +.portal-usage__plan-current { + display: flex; + flex-direction: column; + gap: 0.875rem; +} + +.portal-usage__plan-current-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; +} + +.portal-usage__plan-eyebrow { + display: block; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-5); +} + +.portal-usage__plan-name { + margin: 0.125rem 0 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text-1); +} + +/* Cap progress (free) */ +.portal-usage__cap { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.portal-usage__cap-row { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.8125rem; + color: var(--color-text-2); +} + +.portal-usage__cap-pct { + font-weight: 600; + color: var(--color-text-1); + font-variant-numeric: tabular-nums; +} + +/* Breakdown rows (pro / enterprise) */ +.portal-usage__breakdown { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.portal-usage__breakdown-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.5rem 0; + font-size: 0.8125rem; + border-bottom: 1px solid var(--color-border-light); +} + +.portal-usage__breakdown-row:last-child { + border-bottom: none; +} + +.portal-usage__breakdown-label { + color: var(--color-text-3); +} + +.portal-usage__breakdown-value { + font-weight: 600; + color: var(--color-text-1); + font-variant-numeric: tabular-nums; +} + +.portal-usage__breakdown-row--total { + margin-top: 0.25rem; + padding-top: 0.625rem; + border-top: 1px solid var(--color-border); + font-size: 0.875rem; +} + +.portal-usage__breakdown-row--total .portal-usage__breakdown-label { + color: var(--color-text-1); + font-weight: 600; +} + +.portal-usage__plan-actions { + display: flex; + align-items: center; + gap: 0.625rem; + margin-top: auto; +} + +/* Available plan cards */ +.portal-usage__plans-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.875rem; +} + +@media (max-width: 60rem) { + .portal-usage__plans-grid { + grid-template-columns: 1fr; + } +} + +.portal-usage__plan-card { + display: flex; + flex-direction: column; + gap: 0.625rem; +} + +.portal-usage__plan-card--current { + outline: 1.5px solid color-mix(in srgb, var(--color-green) 45%, transparent); + outline-offset: -1px; +} + +.portal-usage__plan-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.portal-usage__plan-card-name { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-usage__plan-card-price { + display: flex; + align-items: baseline; + gap: 0.375rem; + flex-wrap: wrap; +} + +.portal-usage__plan-card-amount { + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text-1); +} + +.portal-usage__plan-card-cadence { + font-size: 0.75rem; + color: var(--color-text-4); +} + +.portal-usage__plan-card-blurb { + margin: 0; + font-size: 0.8125rem; + line-height: 1.45; + color: var(--color-text-3); +} + +.portal-usage__plan-card-features { + list-style: none; + margin: 0.25rem 0 0.75rem; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.4375rem; + flex: 1; +} + +.portal-usage__plan-card-features li { + display: flex; + align-items: flex-start; + gap: 0.5rem; + font-size: 0.8125rem; + color: var(--color-text-2); +} + +.portal-usage__plan-card-check { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 1rem; + height: 1rem; + margin-top: 0.0625rem; + border-radius: 50%; + background: var(--color-green-light); + color: var(--color-green-dark); + font-size: 0.625rem; + font-weight: 700; +} + +/* Spend-cap card */ +.portal-usage__cap-card { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.portal-usage__cap-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; +} + +.portal-usage__cap-slider { + margin: 0.25rem 0; +} + +.portal-usage__cap-meta { + display: flex; + align-items: center; + gap: 0.625rem; + font-size: 0.75rem; + color: var(--color-text-4); +} + +/* Billing history */ +.portal-usage__hist-date { + font-variant-numeric: tabular-nums; + color: var(--color-text-2); +} + +.portal-usage__hist-amount { + font-weight: 600; + color: var(--color-text-1); + font-variant-numeric: tabular-nums; +} + +.portal-usage__hist-credit { + font-weight: 600; + color: var(--color-green-dark); + font-variant-numeric: tabular-nums; +} + +.portal-usage__hist-skeleton { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* Upgrade modal */ +.portal-usage__modal-body { + margin: 0 0 1rem; + font-size: 0.875rem; + line-height: 1.55; + color: var(--color-text-2); +} + +.portal-usage__modal-bullets { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.portal-usage__modal-bullets li { + display: flex; + align-items: flex-start; + gap: 0.5rem; + font-size: 0.8125rem; + color: var(--color-text-2); +} + +.portal-usage__modal-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.625rem; +} diff --git a/frontend/portal/src/views/Usage.tsx b/frontend/portal/src/views/Usage.tsx new file mode 100644 index 000000000..e46c17cf7 --- /dev/null +++ b/frontend/portal/src/views/Usage.tsx @@ -0,0 +1,105 @@ +import { useState } from "react"; +import { Card, Skeleton, StatusBadge } from "@shared/components"; +import { useTier } from "@portal/contexts/TierContext"; +import { useAsync } from "@portal/hooks/useAsync"; +import { + fetchBillingSummary, + fetchPlanOptions, + type BillingSummary, + type PlanOption, +} from "@portal/api/usage"; +import { UsageChart } from "@portal/components/usage/UsageChart"; +import { BillingKpiStrip } from "@portal/components/usage/BillingKpiStrip"; +import { CurrentPlanCard } from "@portal/components/usage/CurrentPlanCard"; +import { SpendCapControl } from "@portal/components/usage/SpendCapControl"; +import { AvailablePlans } from "@portal/components/usage/AvailablePlans"; +import { BillingHistoryTable } from "@portal/components/usage/BillingHistoryTable"; +import { UpgradeModal } from "@portal/components/usage/UpgradeModal"; +import "@portal/views/Usage.css"; + +export function Usage() { + const { tier } = useTier(); + const [modalOpen, setModalOpen] = useState(false); + const [modalTarget, setModalTarget] = useState(null); + + const summaryState = useAsync( + () => fetchBillingSummary(tier), + [tier], + ); + const summary = summaryState.loading ? null : summaryState.data; + + const plansState = useAsync(() => fetchPlanOptions(), []); + const { data: plans } = plansState; + + function openUpgrade(target: PlanOption | null) { + setModalTarget(target); + setModalOpen(true); + } + + return ( +
    +
    +
    +

    Usage & Billing

    +

    + Your last 30 days of processing, plan, and charges. +

    +
    + + {summary?.planName ?? "—"} + +
    + + + + + +
    + {summary ? ( + openUpgrade(null)} + /> + ) : ( + + + + + )} + {summary ? ( + + ) : ( + + + + + )} +
    + + {plans && plans.length > 0 && ( + // Any plan selection routes through the intent-aware upgrade modal; the + // target plan drives whether the copy is an upgrade pitch or a + // downgrade / sales conversation. + + )} + + + + setModalOpen(false)} + currentTier={tier} + target={modalTarget} + /> +
    + ); +} diff --git a/frontend/shared/assets/stirling-mark-dark.svg b/frontend/shared/assets/stirling-mark-dark.svg new file mode 100644 index 000000000..a6f82dd6f --- /dev/null +++ b/frontend/shared/assets/stirling-mark-dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/shared/assets/stirling-mark-light.svg b/frontend/shared/assets/stirling-mark-light.svg new file mode 100644 index 000000000..62a5b3838 --- /dev/null +++ b/frontend/shared/assets/stirling-mark-light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/shared/assets/stirling-pdf-logo-dark.svg b/frontend/shared/assets/stirling-pdf-logo-dark.svg new file mode 100644 index 000000000..ade693787 --- /dev/null +++ b/frontend/shared/assets/stirling-pdf-logo-dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/shared/assets/stirling-pdf-logo-light.svg b/frontend/shared/assets/stirling-pdf-logo-light.svg new file mode 100644 index 000000000..a4a1a1f87 --- /dev/null +++ b/frontend/shared/assets/stirling-pdf-logo-light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/shared/components/Drawer.tsx b/frontend/shared/components/Drawer.tsx index add63fc20..5e6561e6f 100644 --- a/frontend/shared/components/Drawer.tsx +++ b/frontend/shared/components/Drawer.tsx @@ -1,10 +1,8 @@ -import { useEffect, useId, useRef, type ReactNode } from "react"; +import { useEffect, useId, type ReactNode } from "react"; import { createPortal } from "react-dom"; +import { FocusTrap } from "@mantine/core"; import "@shared/components/Drawer.css"; -const FOCUSABLE_SELECTOR = - 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; - export type DrawerSide = "right" | "left"; export type DrawerWidth = "sm" | "md" | "lg"; @@ -26,9 +24,10 @@ export interface DrawerProps { } /** - * Side drawer — sibling to {@link Modal}. Slides in from `side`, locks body - * scroll, closes on backdrop click or Escape. PipelineDetailDrawer, doc - * detail drawers, etc. should all sit on top of this primitive. + * Side drawer — sibling to {@link Modal}, with our own brand shell. Tab focus + * trapping, initial focus, and focus restoration on close are delegated to + * Mantine's ; we keep the slide-in chrome, scroll lock, and ESC / + * backdrop dismissal. */ export function Drawer({ open, @@ -44,7 +43,6 @@ export function Drawer({ ariaLabel, children, }: DrawerProps) { - const dialogRef = useRef(null); const titleId = useId(); useEffect(() => { @@ -65,40 +63,6 @@ export function Drawer({ }; }, [open]); - useEffect(() => { - if (!open) return; - const previouslyFocused = document.activeElement as HTMLElement | null; - const dialog = dialogRef.current; - const first = dialog?.querySelector(FOCUSABLE_SELECTOR); - first?.focus(); - - function onKey(e: KeyboardEvent) { - if (e.key !== "Tab" || !dialog) return; - const focusables = - dialog.querySelectorAll(FOCUSABLE_SELECTOR); - if (focusables.length === 0) { - e.preventDefault(); - return; - } - const firstEl = focusables[0]; - const lastEl = focusables[focusables.length - 1]; - const active = document.activeElement; - if (e.shiftKey && active === firstEl) { - e.preventDefault(); - lastEl.focus(); - } else if (!e.shiftKey && active === lastEl) { - e.preventDefault(); - firstEl.focus(); - } - } - - document.addEventListener("keydown", onKey); - return () => { - document.removeEventListener("keydown", onKey); - previouslyFocused?.focus?.(); - }; - }, [open]); - if (!open) return null; function onBackdropClick() { @@ -114,57 +78,59 @@ export function Drawer({ onClick={onBackdropClick} role="presentation" /> - + + + + + + + )} +
    {children}
    + {footer &&
    {footer}
    } + +
    , document.body, ); diff --git a/frontend/shared/components/MetricStrip.css b/frontend/shared/components/MetricStrip.css new file mode 100644 index 000000000..c222e6158 --- /dev/null +++ b/frontend/shared/components/MetricStrip.css @@ -0,0 +1,11 @@ +.sui-metric-strip { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.75rem; +} + +@media (max-width: 50rem) { + .sui-metric-strip { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/frontend/shared/components/MetricStrip.stories.tsx b/frontend/shared/components/MetricStrip.stories.tsx new file mode 100644 index 000000000..b2dfc0c68 --- /dev/null +++ b/frontend/shared/components/MetricStrip.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { MetricStrip } from "@shared/components/MetricStrip"; +import { MetricCard } from "@shared/components/MetricCard"; + +const meta: Meta = { + title: "Layout/MetricStrip", + component: MetricStrip, + tags: ["autodocs"], + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Four-up KPI row; collapses to two columns below 50rem. */ +export const Default: Story = { + render: () => ( + + + + + + + ), +}; diff --git a/frontend/shared/components/MetricStrip.tsx b/frontend/shared/components/MetricStrip.tsx new file mode 100644 index 000000000..4b7820069 --- /dev/null +++ b/frontend/shared/components/MetricStrip.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from "react"; +import "@shared/components/MetricStrip.css"; + +export interface MetricStripProps { + children: ReactNode; + className?: string; +} + +/** + * Responsive grid wrapper for a row of {@link MetricCard}s — the prototype's + * "metric strip" (Home, Sources, Usage, Infrastructure all use it). Four-up on + * wide screens, two-up below 50rem. + */ +export function MetricStrip({ children, className }: MetricStripProps) { + return ( +
    + {children} +
    + ); +} diff --git a/frontend/shared/components/Modal.tsx b/frontend/shared/components/Modal.tsx index f12985a15..4ab65c958 100644 --- a/frontend/shared/components/Modal.tsx +++ b/frontend/shared/components/Modal.tsx @@ -1,5 +1,6 @@ -import { useEffect, useId, useRef, type ReactNode } from "react"; +import { useEffect, useId, type ReactNode } from "react"; import { createPortal } from "react-dom"; +import { FocusTrap } from "@mantine/core"; import "@shared/components/Modal.css"; export type ModalWidth = "sm" | "md" | "lg" | "xl"; @@ -25,16 +26,12 @@ export interface ModalProps { children?: ReactNode; } -const FOCUSABLE_SELECTOR = - 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; - /** - * Portal-rendered modal. Backdrop fades in, dialog scales in. ESC and - * click-on-backdrop close by default. Each instance is self-contained — the - * caller owns the open state and the close handler. - * - * Focus is trapped inside the dialog while open and returned to the - * previously-focused element on close. + * Portal-rendered modal with our own brand shell (header / body / footer, + * width presets, backdrop). The hard part — trapping Tab focus inside the + * dialog, initial focus, and restoring focus to the opener on close — is + * delegated to Mantine's rather than hand-rolled. ESC and + * backdrop click close by default; the caller owns the open state. */ export function Modal({ open, @@ -49,7 +46,6 @@ export function Modal({ className, children, }: ModalProps) { - const dialogRef = useRef(null); const titleId = useId(); useEffect(() => { @@ -70,40 +66,6 @@ export function Modal({ }; }, [open]); - useEffect(() => { - if (!open) return; - const previouslyFocused = document.activeElement as HTMLElement | null; - const dialog = dialogRef.current; - const first = dialog?.querySelector(FOCUSABLE_SELECTOR); - first?.focus(); - - function onKey(e: KeyboardEvent) { - if (e.key !== "Tab" || !dialog) return; - const focusables = - dialog.querySelectorAll(FOCUSABLE_SELECTOR); - if (focusables.length === 0) { - e.preventDefault(); - return; - } - const firstEl = focusables[0]; - const lastEl = focusables[focusables.length - 1]; - const active = document.activeElement; - if (e.shiftKey && active === firstEl) { - e.preventDefault(); - lastEl.focus(); - } else if (!e.shiftKey && active === lastEl) { - e.preventDefault(); - firstEl.focus(); - } - } - - document.addEventListener("keydown", onKey); - return () => { - document.removeEventListener("keydown", onKey); - previouslyFocused?.focus?.(); - }; - }, [open]); - if (!open) return null; function onBackdropClick() { @@ -118,53 +80,55 @@ export function Modal({ onClick={onBackdropClick} role="presentation" > -
    e.stopPropagation()} - role="dialog" - aria-modal="true" - aria-labelledby={hasTitle ? titleId : undefined} - aria-label={!hasTitle ? ariaLabel : undefined} - > - {(title || subtitle) && ( -
    -
    - {title && ( -
    - {title} -
    - )} - {subtitle &&
    {subtitle}
    } -
    - -
    - )} -
    {children}
    - {footer &&
    {footer}
    } -
    + + + + + + + )} +
    {children}
    + {footer &&
    {footer}
    } + +
    , document.body, ); diff --git a/frontend/shared/components/StatTile.css b/frontend/shared/components/StatTile.css new file mode 100644 index 000000000..c29455fc7 --- /dev/null +++ b/frontend/shared/components/StatTile.css @@ -0,0 +1,39 @@ +.sui-stat { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.sui-stat__label { + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-5); + font-weight: 600; +} + +.sui-stat__value { + font-size: 0.9375rem; + font-weight: 600; + color: var(--color-text-1); + font-variant-numeric: tabular-nums; +} + +.sui-stat__value--success { + color: var(--color-green-dark); +} +.sui-stat__value--warning { + color: var(--color-amber-dark); +} +.sui-stat__value--danger { + color: var(--color-red); +} + +/* Inline code in a value (masked keys, model ids, URLs) reads as mono meta. */ +.sui-stat__value code { + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--color-text-2); + word-break: break-all; +} diff --git a/frontend/shared/components/StatTile.stories.tsx b/frontend/shared/components/StatTile.stories.tsx new file mode 100644 index 000000000..1fcde5209 --- /dev/null +++ b/frontend/shared/components/StatTile.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { StatTile } from "@shared/components/StatTile"; + +const meta: Meta = { + title: "Primitives/StatTile", + component: StatTile, + tags: ["autodocs"], + parameters: { layout: "padded" }, + args: { label: "Error rate", value: "0.4%", tone: "default" }, + argTypes: { + tone: { + control: "inline-radio", + options: ["default", "success", "warning", "danger"], + }, + }, +}; +export default meta; +type Story = StoryObj; + +export const Playground: Story = {}; + +export const ToneRow: Story = { + render: () => ( +
    + + + + +
    + ), +}; diff --git a/frontend/shared/components/StatTile.tsx b/frontend/shared/components/StatTile.tsx new file mode 100644 index 000000000..3143c44e2 --- /dev/null +++ b/frontend/shared/components/StatTile.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from "react"; +import "@shared/components/StatTile.css"; + +export type StatTileTone = "default" | "success" | "warning" | "danger"; + +export interface StatTileProps { + label: ReactNode; + value: ReactNode; + /** Colours the value (e.g. error rate over threshold). */ + tone?: StatTileTone; + className?: string; +} + +/** + * Compact label-over-value stat used inside detail panels, cards, and metric + * grids (the small sibling of {@link MetricCard}). Value uses tabular figures + * so columns of stats stay aligned. + */ +export function StatTile({ + label, + value, + tone = "default", + className, +}: StatTileProps) { + return ( +
    + {label} + + {value} + +
    + ); +} diff --git a/frontend/shared/components/Table.css b/frontend/shared/components/Table.css new file mode 100644 index 000000000..3b17a6c44 --- /dev/null +++ b/frontend/shared/components/Table.css @@ -0,0 +1,61 @@ +.sui-table-wrap { + width: 100%; + overflow-x: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); +} + +.sui-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8125rem; +} + +.sui-table__th { + text-align: left; + font-weight: 600; + color: var(--color-text-4); + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.625rem 0.875rem; + border-bottom: 1px solid var(--color-border); + white-space: nowrap; +} +.sui-table__th--right, +.sui-table__td--right { + text-align: right; +} +.sui-table__th--center, +.sui-table__td--center { + text-align: center; +} + +.sui-table__td { + padding: 0.625rem 0.875rem; + color: var(--color-text-2); + border-bottom: 1px solid var(--color-border-light); + vertical-align: middle; +} +.sui-table tbody tr:last-child .sui-table__td { + border-bottom: none; +} + +.sui-table__row--interactive { + cursor: pointer; + transition: background var(--motion-fast); +} +.sui-table__row--interactive:hover { + background: var(--color-bg-hover); +} +.sui-table__row--interactive:focus-visible { + outline: 0.125rem solid var(--color-blue); + outline-offset: -0.125rem; +} + +.sui-table__empty { + padding: 2rem; + text-align: center; + color: var(--color-text-4); +} diff --git a/frontend/shared/components/Table.stories.tsx b/frontend/shared/components/Table.stories.tsx new file mode 100644 index 000000000..0434dd67d --- /dev/null +++ b/frontend/shared/components/Table.stories.tsx @@ -0,0 +1,109 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Table, type TableColumn } from "@shared/components/Table"; +import { StatusBadge } from "@shared/components/StatusBadge"; + +interface Region { + id: string; + name: string; + code: string; + status: "healthy" | "degraded"; + docs: number; + latency: string; +} + +const REGIONS: Region[] = [ + { + id: "1", + name: "US East", + code: "us-east-1", + status: "healthy", + docs: 12481, + latency: "41 ms", + }, + { + id: "2", + name: "US West", + code: "us-west-2", + status: "healthy", + docs: 8210, + latency: "63 ms", + }, + { + id: "3", + name: "EU West", + code: "eu-west-1", + status: "degraded", + docs: 3044, + latency: "190 ms", + }, +]; + +const COLUMNS: TableColumn[] = [ + { key: "name", header: "Region", render: (r) => r.name }, + { + key: "code", + header: "Code", + render: (r) => ( + {r.code} + ), + }, + { + key: "status", + header: "Status", + render: (r) => ( + + {r.status} + + ), + }, + { + key: "docs", + header: "Docs 24h", + align: "right", + render: (r) => r.docs.toLocaleString(), + }, + { key: "latency", header: "P95", align: "right", render: (r) => r.latency }, +]; + +const meta: Meta = { + title: "Compound/Table", + component: Table, + tags: ["autodocs"], + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Presentational table — columns own their cell renderers; pass pre-sorted rows. */ +export const Basic: Story = { + render: () => ( + columns={COLUMNS} rows={REGIONS} rowKey={(r) => r.id} /> + ), +}; + +/** With `onRowClick`, rows become focusable + hoverable (keyboard: Enter/Space). */ +export const Interactive: Story = { + render: () => ( + + columns={COLUMNS} + rows={REGIONS} + rowKey={(r) => r.id} + onRowClick={() => {}} + /> + ), +}; + +/** Empty body slot. */ +export const Empty: Story = { + render: () => ( + + columns={COLUMNS} + rows={[]} + rowKey={(r) => r.id} + empty="No regions deployed yet." + /> + ), +}; diff --git a/frontend/shared/components/Table.tsx b/frontend/shared/components/Table.tsx new file mode 100644 index 000000000..6eb90b2d3 --- /dev/null +++ b/frontend/shared/components/Table.tsx @@ -0,0 +1,106 @@ +import type { ReactNode } from "react"; +import "@shared/components/Table.css"; + +export interface TableColumn { + /** Stable column id. */ + key: string; + header: ReactNode; + /** Cell renderer for a row. */ + render: (row: T) => ReactNode; + align?: "left" | "right" | "center"; + /** Optional fixed/min width (any CSS length). */ + width?: string; +} + +export interface TableProps { + columns: TableColumn[]; + rows: T[]; + /** Stable key per row. */ + rowKey: (row: T) => string; + /** Makes rows interactive (hover + click + keyboard). */ + onRowClick?: (row: T) => void; + /** Rendered in place of the body when there are no rows. */ + empty?: ReactNode; + className?: string; +} + +/** + * Minimal data table primitive. Columns own their own cell renderers, so the + * table stays presentational — callers pre-sort/filter and pass the rows they + * want shown. Rows become focusable buttons-in-disguise when `onRowClick` is + * set. + */ +export function Table({ + columns, + rows, + rowKey, + onRowClick, + empty, + className, +}: TableProps) { + const interactive = Boolean(onRowClick); + return ( +
    +
    + + + {columns.map((c) => ( + + ))} + + + + {rows.length === 0 ? ( + + + + ) : ( + rows.map((row) => ( + onRowClick(row) : undefined} + tabIndex={interactive ? 0 : undefined} + role={interactive ? "button" : undefined} + onKeyDown={ + interactive + ? (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onRowClick?.(row); + } + } + : undefined + } + > + {columns.map((c) => ( + + ))} + + )) + )} + +
    + {c.header} +
    + {empty ?? "No data"} +
    + {c.render(row)} +
    +
    + ); +} diff --git a/frontend/shared/components/index.ts b/frontend/shared/components/index.ts index 1e1a26b30..82dd4dd15 100644 --- a/frontend/shared/components/index.ts +++ b/frontend/shared/components/index.ts @@ -14,6 +14,8 @@ export * from "@shared/components/Modal"; // Layout export * from "@shared/components/Stack"; export * from "@shared/components/Inline"; +export * from "@shared/components/MetricStrip"; +export * from "@shared/components/StatTile"; // Feedback export * from "@shared/components/Spinner"; @@ -28,6 +30,7 @@ export * from "@shared/components/Toast"; export * from "@shared/components/Tabs"; export * from "@shared/components/Dropdown"; export * from "@shared/components/Drawer"; +export * from "@shared/components/Table"; // Forms export * from "@shared/components/FormField";