mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Portal: unified-design surfaces (Policies, Users, Components, Agent Builder, Editor deploy) + Settings rebuild (#6696)
Builds the remaining developer-portal surfaces from the unified design and rebuilds Settings, on top of the portal scaffold merged in #6686. All tier-aware, mock-driven (MSW), componentised with Storybook coverage. Touches only `frontend/portal` + `frontend/shared` — the editor is untouched. ## New surfaces - **Policies** — org-wide governance across the five categories (Ingestion / Security / Compliance / Routing / Retention) with a designer + per-doc-type overrides - **Users** — members, roles, invite, tier-scaled SSO/SCIM access - **Components** — embeddable `@stirling/*` SDK catalogue with per-action pricing - **Getting Started** — three-step funnel (use case → analyse a document → API key + snippets) - **Agent Builder** — agent lifecycle (scenarios, tool modes, evals/golden-sets, versions), reached from Sources - **Editor deployment** — deploy/pair/operate the editor (targets, pairing, health, credential rotation, air-gapped bundle), reached from Infrastructure ## Reworks - **Documents** → review/approval queue (confidence, extractions, audit drawer, zero-standing-access elevation); the doc-type catalogue is retained as a second tab - **Pipelines** → golden-set pass column + "Promoted from the Editor" section - **Infrastructure** → new **Models** tab; deeper **Security** (managed / BYOK / HYOK + SOC 2 / ISO 27001 / HIPAA / GDPR / PCI attestations) - **Home** → "What runs on your PDFs" policy summary + tier-aware processing-status strip + pipeline-fork wizard ## Settings & shared - New shared **`SettingsShell`** (grouped left-nav + content pane), modelled on the editor's account-settings modal so both apps can converge on one layout - Portal **Settings** rebuilt on it as scoped sections — Account / Workspace / Admin (Authentication, Active sessions, Early access) ## Brand - Adopt the editor's brand mark + favicon; sidebar reads **Stirling Processor**; app-switcher labels the active app "Processor" ## Mock contract - Every surface follows the 3-layer pattern (typed `api/*` → MSW handler → fixtures); new endpoints documented in `MOCKS.md`. The read contract is backend-ready; writes are marked `// TODO(backend): <METHOD> <path>`. ## Verification - tsc (portal + shared) ✓ · eslint ✓ · dpdm (no circular) ✓ · `build:portal` ✓ · `storybook:build` ✓ · Prettier ✓ ## Deferred (noted, not in scope) - Unified shell / auth / role→surface routing / Workspace=Plan (architectural epic) - Tier rename (Editor / Processor / Bespoke) and the Usage flat-pricing + PAYG quick-amounts + Bespoke modal - Editor adopting the shared `SettingsShell`; converting marked write-stubs into live `api/` seams
This commit is contained in:
@@ -53,12 +53,17 @@ non-2xx). Views consume via `useAsync()` + `useSectionFlags()` (`hooks/useAsync.
|
||||
| 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` |
|
||||
| Users | `GET /v1/users` | `tier` | `fetchUsers` | `UsersResponse` |
|
||||
| Documents | `GET /v1/documents` | `tier` | `fetchDocuments` | `DocumentsResponse` |
|
||||
| Pipelines | `GET /v1/pipelines` · `POST /v1/pipelines/:id/promote-to-policy` | `tier` | `fetchPipelines` · `promoteToPolicy` | `PipelinesResponse` |
|
||||
| Policies | `GET/POST /api/v1/policies` · `GET/DELETE /api/v1/policies/{id}` · `POST /api/v1/policies/{id}/run` | — | `fetchPolicies` · `savePolicy` · `deletePolicy` · `runPolicy` | `PoliciesResponse` · `Policy` |
|
||||
| Agent Builder | `GET /v1/agents` | `tier` | `fetchAgents` | `AgentsResponse` |
|
||||
| Sources | `GET /v1/sources` | `tier` | `fetchSources` | `SourcesResponse` |
|
||||
| Components | `GET /v1/components` | `tier` | `fetchComponents` | `ComponentsResponse` |
|
||||
| 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/models` | `tier` | `fetchModels` | `ModelsResponse` |
|
||||
| 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` |
|
||||
@@ -66,6 +71,7 @@ non-2xx). Views consume via `useAsync()` + `useSectionFlags()` (`hooks/useAsync.
|
||||
| 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[]` |
|
||||
| Editor | `GET /v1/editor/deployment` | `tier` | `fetchEditorDeployment` | `EditorDeploymentResponse` |
|
||||
| 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` | — | — | — |
|
||||
@@ -74,3 +80,7 @@ non-2xx). Views consume via `useAsync()` + `useSectionFlags()` (`hooks/useAsync.
|
||||
|
||||
> Catalogue generated from `mocks/handlers/*.ts`. The `api/<surface>.ts` JSDoc on
|
||||
> each function is the authoritative per-endpoint reference.
|
||||
>
|
||||
> **Policies** targets the **real** backend base `/api/v1/policies` (Stirling's
|
||||
> `PolicyController`) rather than the mock `/v1/...` convention — its contract
|
||||
> mirrors the live policy engine, so MSW can be dropped with no code change.
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<meta name="description" content="Stirling developer portal" />
|
||||
<title>Stirling — Developer Portal</title>
|
||||
<meta name="description" content="Stirling Processor" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<title>Stirling Processor</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -1,8 +1,13 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { Home } from "@portal/views/Home";
|
||||
import { Users } from "@portal/views/Users";
|
||||
import { Documents } from "@portal/views/Documents";
|
||||
import { Pipelines } from "@portal/views/Pipelines";
|
||||
import { Sources } from "@portal/views/Sources";
|
||||
import { AgentBuilder } from "@portal/views/AgentBuilder";
|
||||
import { Policies } from "@portal/views/Policies";
|
||||
import { Components } from "@portal/views/Components";
|
||||
import { EditorAdmin } from "@portal/views/EditorAdmin";
|
||||
import { Infrastructure } from "@portal/views/Infrastructure";
|
||||
import { Usage } from "@portal/views/Usage";
|
||||
import { DeveloperDocs } from "@portal/views/DeveloperDocs";
|
||||
@@ -12,9 +17,14 @@ export function ViewRouter() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path={VIEW_PATHS.home} element={<Home />} />
|
||||
<Route path={VIEW_PATHS.users} element={<Users />} />
|
||||
<Route path={VIEW_PATHS.pipelines} element={<Pipelines />} />
|
||||
<Route path={VIEW_PATHS.sources} element={<Sources />} />
|
||||
<Route path={VIEW_PATHS["agent-builder"]} element={<AgentBuilder />} />
|
||||
<Route path={VIEW_PATHS.policies} element={<Policies />} />
|
||||
<Route path={VIEW_PATHS.documents} element={<Documents />} />
|
||||
<Route path={VIEW_PATHS.components} element={<Components />} />
|
||||
<Route path={VIEW_PATHS.editor} element={<EditorAdmin />} />
|
||||
<Route path={VIEW_PATHS.infrastructure} element={<Infrastructure />} />
|
||||
<Route path={VIEW_PATHS.usage} element={<Usage />} />
|
||||
<Route path={VIEW_PATHS.docs} element={<DeveloperDocs />} />
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { httpJson } from "@portal/api/http";
|
||||
import type { AgentsResponse } from "@portal/mocks/agents";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
export type {
|
||||
Agent,
|
||||
AgentStatus,
|
||||
AgentVersion,
|
||||
AgentsResponse,
|
||||
AgentsSummary,
|
||||
EvalCase,
|
||||
Scenario,
|
||||
ToolMode,
|
||||
} from "@portal/mocks/agents";
|
||||
export { AGENT_STATUS_TONE, TOOL_CATALOGUE } from "@portal/mocks/agents";
|
||||
|
||||
/** GET /v1/agents?tier=… — fleet summary + every agent with its full builder state. */
|
||||
export async function fetchAgents(tier: Tier): Promise<AgentsResponse> {
|
||||
return httpJson<AgentsResponse>(
|
||||
`/v1/agents?tier=${encodeURIComponent(tier)}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { httpJson } from "@portal/api/http";
|
||||
import type { DocumentsResponse } from "@portal/mocks/documents";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
export type {
|
||||
DocAuditEvent,
|
||||
DocAuditKind,
|
||||
DocumentStatus,
|
||||
DocumentsResponse,
|
||||
DocumentsSummary,
|
||||
Extraction,
|
||||
ReviewDocument,
|
||||
} from "@portal/mocks/documents";
|
||||
export {
|
||||
DOC_AUDIT_LABEL,
|
||||
DOC_AUDIT_TONE,
|
||||
DOCUMENT_STATUS_LABEL,
|
||||
DOCUMENT_STATUS_TONE,
|
||||
} from "@portal/mocks/documents";
|
||||
|
||||
/** GET /v1/documents?tier=… — summary strip + the review queue for the tier. */
|
||||
export async function fetchDocuments(tier: Tier): Promise<DocumentsResponse> {
|
||||
return httpJson<DocumentsResponse>(
|
||||
`/v1/documents?tier=${encodeURIComponent(tier)}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { httpJson } from "@portal/api/http";
|
||||
import type { EditorDeploymentResponse } from "@portal/mocks/editorDeploy";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
export type {
|
||||
DeploymentTarget,
|
||||
DeploymentSummary,
|
||||
DeploymentSummaryMetric,
|
||||
EditorDeploymentResponse,
|
||||
EditorInstance,
|
||||
InstanceStatus,
|
||||
PairingMethod,
|
||||
PairingOption,
|
||||
TargetKind,
|
||||
TargetMeta,
|
||||
TargetState,
|
||||
} from "@portal/mocks/editorDeploy";
|
||||
export {
|
||||
INSTANCE_STATUS_LABEL,
|
||||
INSTANCE_STATUS_TONE,
|
||||
TARGET_META,
|
||||
} from "@portal/mocks/editorDeploy";
|
||||
|
||||
/**
|
||||
* GET /v1/editor/deployment?tier=… — the org's Editor deployment: summary
|
||||
* metric strip, deployment targets (with run snippets), pairing options, and
|
||||
* the live instance health table for the tier.
|
||||
*/
|
||||
export async function fetchEditorDeployment(
|
||||
tier: Tier,
|
||||
): Promise<EditorDeploymentResponse> {
|
||||
return httpJson<EditorDeploymentResponse>(
|
||||
`/v1/editor/deployment?tier=${encodeURIComponent(tier)}`,
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { httpJson } from "@portal/api/http";
|
||||
import type { Vertical } from "@shared/data/endpoints";
|
||||
|
||||
export type {
|
||||
Endpoint,
|
||||
EndpointSchema,
|
||||
EndpointTierGate,
|
||||
Vertical,
|
||||
VerticalKey,
|
||||
} from "@shared/data/endpoints";
|
||||
|
||||
/** GET /v1/endpoints — verticals plus their endpoints. */
|
||||
export async function fetchVerticals(): Promise<Vertical[]> {
|
||||
return httpJson<Vertical[]>("/v1/endpoints");
|
||||
}
|
||||
@@ -13,10 +13,13 @@ export type {
|
||||
ActivityKind,
|
||||
KpiEntry,
|
||||
OnboardingStep,
|
||||
PipelineStage,
|
||||
PipelineTemplate,
|
||||
RegionHealth,
|
||||
UsagePoint,
|
||||
UsageSeriesResponse,
|
||||
} from "@portal/mocks/home";
|
||||
export { PIPELINE_STAGES, PIPELINE_TEMPLATES } from "@portal/mocks/home";
|
||||
|
||||
/** GET /v1/analytics/usage?window=30d */
|
||||
export async function fetchUsageSeries(): Promise<UsageSeriesResponse> {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
ApiKey,
|
||||
AuditLogResponse,
|
||||
DeploymentRegion,
|
||||
ModelsResponse,
|
||||
RecentDeployment,
|
||||
SecurityConfig,
|
||||
StorageConfig,
|
||||
@@ -14,20 +15,32 @@ export type {
|
||||
ApiKey,
|
||||
ApiKeyPermission,
|
||||
ApiKeyStatus,
|
||||
AttestationStatus,
|
||||
AuditCategory,
|
||||
AuditEvent,
|
||||
AuditLogResponse,
|
||||
AuditStatus,
|
||||
AuditSummary,
|
||||
CertStatus,
|
||||
ComplianceAttestation,
|
||||
ComplianceCert,
|
||||
DataResidency,
|
||||
DeploymentRegion,
|
||||
DeploymentStatus,
|
||||
IpAllowEntry,
|
||||
KeyManagement,
|
||||
KeyMode,
|
||||
ModelCostUnit,
|
||||
ModelEntry,
|
||||
ModelProvider,
|
||||
ModelsResponse,
|
||||
ModelsSummary,
|
||||
ModelStatus,
|
||||
ModelType,
|
||||
RecentDeployment,
|
||||
RegionStatus,
|
||||
RetentionWindow,
|
||||
RoutingRule,
|
||||
SecurityConfig,
|
||||
StorageConfig,
|
||||
StorageProvider,
|
||||
@@ -59,6 +72,11 @@ export async function fetchSecurity(tier: Tier): Promise<SecurityConfig> {
|
||||
return httpJson<SecurityConfig>(`/v1/infrastructure/security${q(tier)}`);
|
||||
}
|
||||
|
||||
/** GET /v1/infrastructure/models?tier=… */
|
||||
export async function fetchModels(tier: Tier): Promise<ModelsResponse> {
|
||||
return httpJson<ModelsResponse>(`/v1/infrastructure/models${q(tier)}`);
|
||||
}
|
||||
|
||||
/** GET /v1/infrastructure/storage?tier=… */
|
||||
export async function fetchStorage(tier: Tier): Promise<StorageConfig> {
|
||||
return httpJson<StorageConfig>(`/v1/infrastructure/storage${q(tier)}`);
|
||||
|
||||
@@ -9,6 +9,8 @@ export type {
|
||||
PipelineMetrics,
|
||||
PipelinesResponse,
|
||||
PipelineStatus,
|
||||
PromotedPipeline,
|
||||
PromotedStatus,
|
||||
SchemaDrift,
|
||||
StageKey,
|
||||
StageSummary,
|
||||
@@ -20,3 +22,18 @@ export async function fetchPipelines(tier: Tier): Promise<PipelinesResponse> {
|
||||
`/v1/pipelines?tier=${encodeURIComponent(tier)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote a watch-folder-derived pipeline into a governed org policy, so its
|
||||
* rules apply fleet-wide instead of just to the originating flow.
|
||||
*
|
||||
* TODO(backend): POST /v1/pipelines/{id}/promote-to-policy — should create the
|
||||
* policy from the pipeline's stages and return the new policy id. The mock
|
||||
* handler resolves `{ ok: true }`; the UI treats a resolved promise as accepted.
|
||||
*/
|
||||
export async function promoteToPolicy(id: string): Promise<{ ok: true }> {
|
||||
return httpJson<{ ok: true }>(
|
||||
`/v1/pipelines/${encodeURIComponent(id)}/promote-to-policy`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { httpJson } from "@portal/api/http";
|
||||
import type { PoliciesResponse, Policy } from "@portal/mocks/policies";
|
||||
|
||||
/**
|
||||
* Policies service layer — the backend contract.
|
||||
*
|
||||
* Unlike every other portal surface (which use the mock `/v1/...` base), this
|
||||
* one calls the REAL Stirling policy API base `/api/v1/policies` so it is
|
||||
* genuinely plug-and-play: drop MSW and these exact calls hit the live backend
|
||||
* (PolicyController). The list response is the portal's catalogue shape; the
|
||||
* single-policy / create / delete / run calls match the backend records.
|
||||
*/
|
||||
|
||||
export type {
|
||||
CatalogueEntry,
|
||||
DecoratedPolicy,
|
||||
InputSpec,
|
||||
OutputSpec,
|
||||
PipelineStep,
|
||||
PoliciesResponse,
|
||||
PoliciesSummary,
|
||||
Policy,
|
||||
PolicyActivityItem,
|
||||
PolicyCategory,
|
||||
PolicyConfigDef,
|
||||
PolicyField,
|
||||
PolicyFieldType,
|
||||
PolicyRowStatus,
|
||||
PolicySetupResult,
|
||||
PolicySource,
|
||||
PolicyState,
|
||||
PolicyStats,
|
||||
PolicyStatus,
|
||||
TriggerConfig,
|
||||
} from "@portal/mocks/policies";
|
||||
export {
|
||||
ENDPOINT_LABELS,
|
||||
POLICY_CATEGORIES,
|
||||
POLICY_CONFIG,
|
||||
POLICY_DOC_TYPES,
|
||||
POLICY_SOURCES,
|
||||
TOOL_ENDPOINTS,
|
||||
humanizeEndpoint,
|
||||
} from "@portal/mocks/policies";
|
||||
|
||||
/** GET /api/v1/policies — the catalogue + every configured policy. */
|
||||
export async function fetchPolicies(): Promise<PoliciesResponse> {
|
||||
return httpJson<PoliciesResponse>("/api/v1/policies");
|
||||
}
|
||||
|
||||
/** GET /api/v1/policies/{id} — one stored policy's raw record. */
|
||||
export async function fetchPolicy(id: string): Promise<Policy> {
|
||||
return httpJson<Policy>(`/api/v1/policies/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/policies — create (blank id) or update (matched id). The backend
|
||||
* assigns owner + team server-side and returns the stored policy with its id.
|
||||
*/
|
||||
export async function savePolicy(policy: Policy): Promise<Policy> {
|
||||
return httpJson<Policy>("/api/v1/policies", { method: "POST", body: policy });
|
||||
}
|
||||
|
||||
/** DELETE /api/v1/policies/{id} — remove a stored policy. */
|
||||
export async function deletePolicy(id: string): Promise<void> {
|
||||
await httpJson<void>(`/api/v1/policies/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/** The async run acknowledgement: a run id to poll for status. */
|
||||
export interface PolicyRunResponse {
|
||||
status: boolean;
|
||||
/** The run id (poll GET /api/v1/policies/run/{id} for status). */
|
||||
fileId: string | null;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/policies/{id}/run — run a stored policy now. The real endpoint
|
||||
* is multipart (the documents to process); the portal has no files to attach,
|
||||
* so this triggers the policy on whatever the backend has queued and returns a
|
||||
* run id. Runs regardless of the policy's enabled flag.
|
||||
*/
|
||||
export async function runPolicy(id: string): Promise<PolicyRunResponse> {
|
||||
return httpJson<PolicyRunResponse>(
|
||||
`/api/v1/policies/${encodeURIComponent(id)}/run`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { httpJson } from "@portal/api/http";
|
||||
import type { ComponentsResponse } from "@portal/mocks/sdkComponents";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
export type {
|
||||
BillingUnit,
|
||||
ComponentMaturity,
|
||||
ComponentPricing,
|
||||
ComponentProp,
|
||||
ComponentsResponse,
|
||||
ComponentsSummary,
|
||||
Framework,
|
||||
MaturityMeta,
|
||||
SdkComponent,
|
||||
} from "@portal/mocks/sdkComponents";
|
||||
export {
|
||||
BILLING_UNIT_LABEL,
|
||||
MATURITY_META,
|
||||
formatPrice,
|
||||
isUnlocked,
|
||||
} from "@portal/mocks/sdkComponents";
|
||||
|
||||
/** GET /v1/components?tier=… — summary strip + the embeddable SDK catalogue. */
|
||||
export async function fetchComponents(tier: Tier): Promise<ComponentsResponse> {
|
||||
return httpJson<ComponentsResponse>(
|
||||
`/v1/components?tier=${encodeURIComponent(tier)}`,
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,11 @@ import type { SettingsSnapshot } from "@portal/mocks/settings";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
export type {
|
||||
ActiveSession,
|
||||
BetaFeature,
|
||||
NotificationDefault,
|
||||
RegionOption,
|
||||
SecuritySettings,
|
||||
SettingsSnapshot,
|
||||
} from "@portal/mocks/settings";
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { httpJson } from "@portal/api/http";
|
||||
import type { UsersResponse } from "@portal/mocks/users";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
export type {
|
||||
AccessControls,
|
||||
Member,
|
||||
MemberStatus,
|
||||
Role,
|
||||
RoleId,
|
||||
UsersResponse,
|
||||
UsersSummary,
|
||||
} from "@portal/mocks/users";
|
||||
export {
|
||||
MEMBER_STATUS_TONE,
|
||||
ROLES,
|
||||
ROLE_LABEL,
|
||||
ROLE_TONE,
|
||||
} from "@portal/mocks/users";
|
||||
|
||||
/** GET /v1/users?tier=… — summary strip, members table, role catalogue, access. */
|
||||
export async function fetchUsers(tier: Tier): Promise<UsersResponse> {
|
||||
return httpJson<UsersResponse>(`/v1/users?tier=${encodeURIComponent(tier)}`);
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
.portal-doctype {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.portal-doctype__head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.portal-doctype__title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-doctype__sub {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.portal-doctype__tabs {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.portal-doctype__tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.3125rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-3);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pill);
|
||||
transition:
|
||||
border-color var(--motion-fast),
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-doctype__tab:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-doctype__tab.is-active {
|
||||
color: var(--portal-tab-accent, var(--color-blue));
|
||||
border-color: var(--portal-tab-accent, var(--color-blue));
|
||||
background: var(--color-surface);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.portal-doctype__tab-dot {
|
||||
width: 0.4375rem;
|
||||
height: 0.4375rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.portal-doctype__tab-count {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-5);
|
||||
}
|
||||
|
||||
/* Vertical groups */
|
||||
.portal-doctype__groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.portal-doctype__group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
.portal-doctype__group-title {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.portal-doctype__group-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-5);
|
||||
}
|
||||
|
||||
.portal-doctype__scroller {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.5rem;
|
||||
scroll-snap-type: x mandatory;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.portal-doctype__scroller::-webkit-scrollbar {
|
||||
height: 0.375rem;
|
||||
}
|
||||
.portal-doctype__scroller::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-hover);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.portal-doctype__card {
|
||||
flex: 0 0 17rem;
|
||||
display: flex;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
scroll-snap-align: start;
|
||||
transition:
|
||||
border-color var(--motion-fast),
|
||||
box-shadow var(--motion-fast),
|
||||
transform var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-doctype__card:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-0.0625rem);
|
||||
}
|
||||
|
||||
.portal-doctype__card-accent {
|
||||
width: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.portal-doctype__card-body {
|
||||
flex: 1 1 auto;
|
||||
padding: 0.875rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-doctype__card-eyebrow {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-5);
|
||||
}
|
||||
|
||||
.portal-doctype__card-title {
|
||||
margin: 0.125rem 0 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-doctype__card-desc {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
color: var(--color-text-4);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.portal-doctype__card-meta {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-doctype__regions {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.portal-doctype__regions-more {
|
||||
color: var(--color-text-5);
|
||||
}
|
||||
|
||||
.portal-doctype__card-cta {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: opacity var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-doctype__card-cta:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.portal-doctype__skel,
|
||||
.portal-doctype__card-skel {
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-bg-muted) 0%,
|
||||
var(--color-bg-hover) 50%,
|
||||
var(--color-bg-muted) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s linear infinite;
|
||||
}
|
||||
|
||||
.portal-doctype__skel {
|
||||
display: inline-block;
|
||||
height: 0.75rem;
|
||||
}
|
||||
|
||||
.portal-doctype__card-skel {
|
||||
flex: 0 0 17rem;
|
||||
height: 8rem;
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import { DocumentTypeGrid } from "@portal/components/DocumentTypeGrid";
|
||||
|
||||
const meta: Meta<typeof DocumentTypeGrid> = {
|
||||
title: "Portal/Home/DocumentTypeGrid",
|
||||
component: DocumentTypeGrid,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "72rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DocumentTypeGrid>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Loading: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/v1/endpoints", async () => {
|
||||
await delay("infinite");
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [http.get("/v1/endpoints", () => HttpResponse.json([]))],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,181 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
EmptyState,
|
||||
Skeleton,
|
||||
StatusBadge,
|
||||
Tabs,
|
||||
type TabItem,
|
||||
} from "@shared/components";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
import {
|
||||
fetchVerticals,
|
||||
type Endpoint,
|
||||
type Vertical,
|
||||
type VerticalKey,
|
||||
} from "@portal/api/endpoints";
|
||||
import "@portal/components/DocumentTypeGrid.css";
|
||||
|
||||
type ActiveTab = VerticalKey | "all";
|
||||
|
||||
const TIER_LABEL = ["Free", "Paid", "Enterprise"] as const;
|
||||
const TIER_TONE = ["success", "info", "purple"] as const;
|
||||
|
||||
function tierBadge(tier: 0 | 1 | 2) {
|
||||
return (
|
||||
<StatusBadge tone={TIER_TONE[tier]} size="sm">
|
||||
{TIER_LABEL[tier]}
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
|
||||
function EndpointCard({
|
||||
endpoint,
|
||||
vertical,
|
||||
}: {
|
||||
endpoint: Endpoint;
|
||||
vertical: Vertical;
|
||||
}) {
|
||||
const visibleRegions = endpoint.regions.slice(0, 2);
|
||||
const extraRegions = endpoint.regions.length - visibleRegions.length;
|
||||
return (
|
||||
<article className="portal-doctype__card">
|
||||
<div
|
||||
className="portal-doctype__card-accent"
|
||||
style={{ background: vertical.color }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="portal-doctype__card-body">
|
||||
<div className="portal-doctype__card-eyebrow">
|
||||
{vertical.label.toUpperCase()}
|
||||
</div>
|
||||
<h3 className="portal-doctype__card-title">{endpoint.name}</h3>
|
||||
<p className="portal-doctype__card-desc">{endpoint.desc}</p>
|
||||
<div className="portal-doctype__card-meta">
|
||||
<span className="portal-doctype__regions">
|
||||
{visibleRegions.join(" · ")}
|
||||
{extraRegions > 0 && (
|
||||
<span className="portal-doctype__regions-more">
|
||||
{" "}
|
||||
+{extraRegions} more
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{tierBadge(endpoint.tier)}
|
||||
</div>
|
||||
<a
|
||||
className="portal-doctype__card-cta"
|
||||
style={{ color: vertical.color }}
|
||||
href={`#${endpoint.endpoint}`}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
Explore <span aria-hidden>→</span>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function GridSkeleton() {
|
||||
return (
|
||||
<div className="portal-doctype__groups" aria-hidden>
|
||||
{Array.from({ length: 2 }).map((_, gi) => (
|
||||
<div key={gi} className="portal-doctype__group">
|
||||
<div className="portal-doctype__group-head">
|
||||
<Skeleton shape="circle" width="0.4375rem" height="0.4375rem" />
|
||||
<Skeleton width="6rem" />
|
||||
</div>
|
||||
<div className="portal-doctype__scroller">
|
||||
{Array.from({ length: 4 }).map((_, ci) => (
|
||||
<Skeleton key={ci} shape="rect" width="17rem" height="8rem" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentTypeGrid() {
|
||||
const [tab, setTab] = useState<ActiveTab>("all");
|
||||
const state = useAsync<Vertical[]>(() => fetchVerticals(), []);
|
||||
const { data: verticals } = state;
|
||||
const { isLoading, isEmpty } = useSectionFlags(state);
|
||||
const hasVerticals = verticals !== null && verticals.length > 0;
|
||||
|
||||
const tabItems: TabItem<ActiveTab>[] = hasVerticals
|
||||
? [
|
||||
{ key: "all", label: "All" },
|
||||
...verticals.map<TabItem<ActiveTab>>((v) => ({
|
||||
key: v.key,
|
||||
label: v.label,
|
||||
count: v.endpoints.length,
|
||||
accentColor: v.color,
|
||||
dotColor: v.color,
|
||||
})),
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<section className="portal-doctype" aria-label="Document types">
|
||||
<header className="portal-doctype__head">
|
||||
<h2 className="portal-doctype__title">Document types</h2>
|
||||
<p className="portal-doctype__sub">
|
||||
Typed endpoints across every supported vertical — each carries a
|
||||
schema, region availability and tier gate.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{hasVerticals && (
|
||||
<Tabs<ActiveTab>
|
||||
items={tabItems}
|
||||
activeKey={tab}
|
||||
onChange={setTab}
|
||||
ariaLabel="Document type verticals"
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading && <GridSkeleton />}
|
||||
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
title="No document types yet"
|
||||
description="When endpoints are registered they'll appear in this catalogue."
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasVerticals && (
|
||||
<div className="portal-doctype__groups">
|
||||
{(tab === "all"
|
||||
? verticals
|
||||
: verticals.filter((v) => v.key === tab)
|
||||
).map((v) => (
|
||||
<div key={v.key} className="portal-doctype__group">
|
||||
{tab === "all" && (
|
||||
<div className="portal-doctype__group-head">
|
||||
<span
|
||||
className="portal-doctype__tab-dot"
|
||||
style={{ background: v.color }}
|
||||
aria-hidden
|
||||
/>
|
||||
<h3 className="portal-doctype__group-title">{v.label}</h3>
|
||||
<span className="portal-doctype__group-count">
|
||||
{v.endpoints.length} endpoints
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="portal-doctype__scroller">
|
||||
{v.endpoints.map((endpoint) => (
|
||||
<EndpointCard
|
||||
key={endpoint.endpoint}
|
||||
endpoint={endpoint}
|
||||
vertical={v}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
.portal-fork__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.875rem;
|
||||
}
|
||||
|
||||
.portal-fork__title {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-fork__sub {
|
||||
margin: 0.125rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
max-width: 32rem;
|
||||
}
|
||||
|
||||
/* Template picker */
|
||||
.portal-fork__templates {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.portal-fork__templates {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.portal-fork__template {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
padding: 0.75rem 0.875rem;
|
||||
text-align: left;
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-left: 3px solid var(--accent, var(--color-blue));
|
||||
border-radius: var(--radius-md);
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
border-color var(--motion-fast),
|
||||
transform var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-fork__template[data-accent="blue"] {
|
||||
--accent: var(--color-blue);
|
||||
}
|
||||
.portal-fork__template[data-accent="purple"] {
|
||||
--accent: var(--color-purple);
|
||||
}
|
||||
.portal-fork__template[data-accent="green"] {
|
||||
--accent: var(--color-green);
|
||||
}
|
||||
.portal-fork__template[data-accent="amber"] {
|
||||
--accent: var(--color-amber);
|
||||
}
|
||||
|
||||
.portal-fork__template:hover {
|
||||
background: var(--color-bg-hover);
|
||||
border-color: var(--color-border);
|
||||
border-left-color: var(--accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.portal-fork__template-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-fork__template-blurb {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.portal-fork__template-types {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
/* Build / ready state */
|
||||
.portal-fork__build-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 0.875rem;
|
||||
}
|
||||
|
||||
.portal-fork__build-head strong {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-fork__build-head span {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-fork__stages {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.portal-fork__stages {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.portal-fork__stage {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
opacity: 0.55;
|
||||
transition:
|
||||
opacity var(--motion-base),
|
||||
border-color var(--motion-base),
|
||||
background var(--motion-base);
|
||||
}
|
||||
|
||||
.portal-fork__stage.is-active,
|
||||
.portal-fork__stage.is-done {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.portal-fork__stage.is-active {
|
||||
border-color: var(--color-blue);
|
||||
background: var(--color-blue-light);
|
||||
}
|
||||
|
||||
.portal-fork__stage.is-done {
|
||||
border-color: color-mix(in srgb, var(--color-green) 35%, transparent);
|
||||
background: var(--color-green-light);
|
||||
}
|
||||
|
||||
.portal-fork__stage-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 50%;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-fork__stage.is-done .portal-fork__stage-mark {
|
||||
background: var(--color-green);
|
||||
border-color: var(--color-green);
|
||||
color: var(--color-text-on-accent);
|
||||
}
|
||||
|
||||
.portal-fork__stage.is-active .portal-fork__stage-mark {
|
||||
border-color: var(--color-blue);
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
.portal-fork__stage-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.portal-fork__stage-text strong {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-fork__stage-text span {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.portal-fork__stage-spin {
|
||||
position: absolute;
|
||||
top: 0.625rem;
|
||||
right: 0.625rem;
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
border: 2px solid color-mix(in srgb, var(--color-blue) 30%, transparent);
|
||||
border-top-color: var(--color-blue);
|
||||
border-radius: 50%;
|
||||
animation: portal-fork-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes portal-fork-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.portal-fork__stage-spin {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.portal-fork__build-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { PipelineForkWizard } from "@portal/components/PipelineForkWizard";
|
||||
|
||||
const meta: Meta<typeof PipelineForkWizard> = {
|
||||
title: "Portal/Home/PipelineForkWizard",
|
||||
component: PipelineForkWizard,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "44rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PipelineForkWizard>;
|
||||
|
||||
/** Pick a template to watch the deterministic four-stage build animation. */
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button, Card, Chip, StatusBadge } from "@shared/components";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
import {
|
||||
PIPELINE_STAGES,
|
||||
PIPELINE_TEMPLATES,
|
||||
type PipelineTemplate,
|
||||
} from "@portal/api/home";
|
||||
import "@portal/components/PipelineForkWizard.css";
|
||||
|
||||
/**
|
||||
* Wizard phases:
|
||||
* - `pick` — choose a starter template
|
||||
* - `building` — deterministic stage-by-stage build animation
|
||||
* - `ready` — all four stages lit; offer deploy
|
||||
*/
|
||||
type Phase = "pick" | "building" | "ready";
|
||||
|
||||
/** Time each build stage stays "in progress" before the next lights up. */
|
||||
const STAGE_STEP_MS = 550;
|
||||
|
||||
export function PipelineForkWizard() {
|
||||
const { setActiveView } = useView();
|
||||
const [phase, setPhase] = useState<Phase>("pick");
|
||||
const [template, setTemplate] = useState<PipelineTemplate | null>(null);
|
||||
// How many stages have completed. Drives both the animation and the
|
||||
// pick→building→ready transitions; advanced purely by a fixed-interval timer
|
||||
// so the sequence is identical on every run (no Math.random / Date.now).
|
||||
const [builtStages, setBuiltStages] = useState(0);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
// Advance one stage per tick while building; settle into `ready` once all
|
||||
// stages are done. The effect re-arms itself on each builtStages change
|
||||
// rather than holding a single long-lived interval, so cleanup is trivial.
|
||||
useEffect(() => {
|
||||
if (phase !== "building") return;
|
||||
if (builtStages >= PIPELINE_STAGES.length) {
|
||||
setPhase("ready");
|
||||
return;
|
||||
}
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
setBuiltStages((n) => n + 1);
|
||||
}, STAGE_STEP_MS);
|
||||
return () => {
|
||||
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [phase, builtStages]);
|
||||
|
||||
function fork(t: PipelineTemplate) {
|
||||
setTemplate(t);
|
||||
setBuiltStages(0);
|
||||
setPhase("building");
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setPhase("pick");
|
||||
setTemplate(null);
|
||||
setBuiltStages(0);
|
||||
}
|
||||
|
||||
function deploy() {
|
||||
// TODO(backend): POST /v1/pipelines { templateId, name } to create the
|
||||
// forked pipeline. Without a backend, route to the pipelines list.
|
||||
setActiveView("pipelines");
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding="loose" className="portal-fork">
|
||||
<header className="portal-fork__head">
|
||||
<div>
|
||||
<h2 className="portal-fork__title">Fork a starter pipeline</h2>
|
||||
<p className="portal-fork__sub">
|
||||
Clone a proven workflow and tune it — every template ships the same
|
||||
four-stage backbone.
|
||||
</p>
|
||||
</div>
|
||||
{phase !== "pick" && template && (
|
||||
<StatusBadge tone={phase === "ready" ? "success" : "info"} size="sm">
|
||||
{phase === "ready" ? "Ready to deploy" : "Building…"}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{phase === "pick" && (
|
||||
<div className="portal-fork__templates">
|
||||
{PIPELINE_TEMPLATES.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className="portal-fork__template"
|
||||
data-accent={t.accent}
|
||||
onClick={() => fork(t)}
|
||||
>
|
||||
<span className="portal-fork__template-name">{t.name}</span>
|
||||
<span className="portal-fork__template-blurb">{t.blurb}</span>
|
||||
<span className="portal-fork__template-types">
|
||||
{t.docTypes.map((d) => (
|
||||
<Chip key={d} size="sm" tone="neutral">
|
||||
{d}
|
||||
</Chip>
|
||||
))}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase !== "pick" && template && (
|
||||
<div className="portal-fork__build">
|
||||
<div className="portal-fork__build-head">
|
||||
<strong>{template.name}</strong>
|
||||
<span>{template.blurb}</span>
|
||||
</div>
|
||||
|
||||
<ol className="portal-fork__stages">
|
||||
{PIPELINE_STAGES.map((stage, i) => {
|
||||
const done = i < builtStages;
|
||||
const active = phase === "building" && i === builtStages;
|
||||
const cls =
|
||||
"portal-fork__stage" +
|
||||
(done ? " is-done" : "") +
|
||||
(active ? " is-active" : "");
|
||||
return (
|
||||
<li key={stage.key} className={cls}>
|
||||
<span className="portal-fork__stage-mark" aria-hidden>
|
||||
{done ? "✓" : i + 1}
|
||||
</span>
|
||||
<span className="portal-fork__stage-text">
|
||||
<strong>{stage.label}</strong>
|
||||
<span>{stage.detail}</span>
|
||||
</span>
|
||||
{active && (
|
||||
<span className="portal-fork__stage-spin" aria-hidden />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
<div className="portal-fork__build-actions">
|
||||
<Button variant="ghost" size="sm" onClick={reset}>
|
||||
{phase === "ready" ? "Pick another" : "Cancel"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="gradient"
|
||||
size="sm"
|
||||
onClick={deploy}
|
||||
disabled={phase !== "ready"}
|
||||
trailingIcon={<span aria-hidden>→</span>}
|
||||
>
|
||||
{phase === "ready" ? "Deploy pipeline" : "Building…"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
.portal-policysum__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.125rem 0.875rem;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.portal-policysum__title {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-policysum__sub {
|
||||
margin: 0.125rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
max-width: 34rem;
|
||||
}
|
||||
|
||||
.portal-policysum__cat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.portal-policysum__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
flex: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border-light);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.portal-policysum__cat-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-policysum__cat-text strong {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-policysum__cat-text span {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-policysum__rule {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-policysum__loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.125rem;
|
||||
}
|
||||
|
||||
.portal-policysum__loading-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import { PolicySummary } from "@portal/components/PolicySummary";
|
||||
|
||||
const meta: Meta<typeof PolicySummary> = {
|
||||
title: "Portal/Home/PolicySummary",
|
||||
component: PolicySummary,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "60rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PolicySummary>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Loading: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/api/v1/policies", async () => {
|
||||
await delay("infinite");
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/api/v1/policies", () =>
|
||||
HttpResponse.json({
|
||||
summary: { active: 0, paused: 0, categories: 0, docsEnforced: 0 },
|
||||
catalogue: [],
|
||||
}),
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
Skeleton,
|
||||
StatusBadge,
|
||||
Table,
|
||||
type TableColumn,
|
||||
} from "@shared/components";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
import {
|
||||
fetchPolicies,
|
||||
type CatalogueEntry,
|
||||
type PoliciesResponse,
|
||||
} from "@portal/api/policies";
|
||||
import { policyIcon } from "@portal/components/policies/policyIcons";
|
||||
import "@portal/components/PolicySummary.css";
|
||||
|
||||
/**
|
||||
* Each row's display state collapses a category's facts into one of three
|
||||
* mutually-exclusive shapes:
|
||||
*
|
||||
* - `locked` — a coming-soon category; show a "Soon" affordance
|
||||
* - `active` — a configured policy that's enabled; offer "Configure"
|
||||
* - `off` — available but not set up (or paused); offer "Set up"
|
||||
*/
|
||||
type RowState = "locked" | "active" | "off";
|
||||
|
||||
interface PolicyRow {
|
||||
entry: CatalogueEntry;
|
||||
state: RowState;
|
||||
}
|
||||
|
||||
const STATE_BADGE: Record<
|
||||
RowState,
|
||||
{ tone: "success" | "neutral" | "info"; label: string }
|
||||
> = {
|
||||
active: { tone: "success", label: "Active" },
|
||||
off: { tone: "neutral", label: "Off" },
|
||||
locked: { tone: "info", label: "Soon" },
|
||||
};
|
||||
|
||||
function toRow(entry: CatalogueEntry): PolicyRow {
|
||||
if (entry.category.comingSoon) return { entry, state: "locked" };
|
||||
const active = entry.policy?.state.status === "active";
|
||||
return { entry, state: active ? "active" : "off" };
|
||||
}
|
||||
|
||||
export function PolicySummary() {
|
||||
const { setActiveView } = useView();
|
||||
const state = useAsync<PoliciesResponse>(() => fetchPolicies(), []);
|
||||
const { data } = state;
|
||||
const { isLoading, isEmpty } = useSectionFlags(state);
|
||||
|
||||
const goToPolicies = () => setActiveView("policies");
|
||||
|
||||
const columns: TableColumn<PolicyRow>[] = [
|
||||
{
|
||||
key: "category",
|
||||
header: "Policy",
|
||||
render: ({ entry }) => (
|
||||
<div className="portal-policysum__cat">
|
||||
<span className="portal-policysum__icon" aria-hidden>
|
||||
{policyIcon(entry.category.icon)}
|
||||
</span>
|
||||
<div className="portal-policysum__cat-text">
|
||||
<strong>{entry.category.label}</strong>
|
||||
<span>{entry.category.desc}</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
width: "7rem",
|
||||
render: ({ state }) => {
|
||||
const badge = STATE_BADGE[state];
|
||||
return (
|
||||
<StatusBadge tone={badge.tone} size="sm">
|
||||
{badge.label}
|
||||
</StatusBadge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "rule",
|
||||
header: "Active rule",
|
||||
render: ({ entry, state }) => (
|
||||
<span className="portal-policysum__rule">
|
||||
{state === "active" ? entry.config.summary : "No rule enforced yet"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "action",
|
||||
header: "",
|
||||
align: "right",
|
||||
width: "9rem",
|
||||
render: ({ state }) => {
|
||||
if (state === "locked") {
|
||||
return (
|
||||
<Button size="sm" variant="ghost" onClick={goToPolicies}>
|
||||
Coming soon
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={state === "active" ? "ghost" : "outline"}
|
||||
onClick={goToPolicies}
|
||||
>
|
||||
{state === "active" ? "Configure" : "Set up"}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const rows: PolicyRow[] = data?.catalogue.map(toRow) ?? [];
|
||||
|
||||
return (
|
||||
<section className="portal-policysum" aria-label="What runs on your PDFs">
|
||||
<Card padding="none">
|
||||
<header className="portal-policysum__head">
|
||||
<div>
|
||||
<h2 className="portal-policysum__title">What runs on your PDFs</h2>
|
||||
<p className="portal-policysum__sub">
|
||||
Standing automations every document passes through, regardless of
|
||||
which pipeline handles it.
|
||||
</p>
|
||||
</div>
|
||||
{data && (
|
||||
<StatusBadge tone="info" size="sm">
|
||||
{data.summary.active} / {data.summary.categories} active
|
||||
</StatusBadge>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{isLoading && (
|
||||
<div className="portal-policysum__loading" aria-hidden>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="portal-policysum__loading-row">
|
||||
<Skeleton width="9rem" />
|
||||
<Skeleton width="60%" height="0.625rem" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title="No policies yet"
|
||||
description="Once policies are configured, the categories appear here."
|
||||
/>
|
||||
)}
|
||||
|
||||
{data && rows.length > 0 && (
|
||||
<Table
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(r) => r.entry.category.id}
|
||||
onRowClick={goToPolicies}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
.portal-statusstrip--free {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* The Banner body already provides padding; lay out the free meter inside it. */
|
||||
.portal-statusstrip--free .sui-banner__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.portal-statusstrip__free-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-statusstrip__free-label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.portal-statusstrip__free-label strong {
|
||||
color: var(--color-text-1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.portal-statusstrip__free-pct {
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* Paid strip: single inline row */
|
||||
.portal-statusstrip--paid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.portal-statusstrip__plan {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-statusstrip__dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.portal-statusstrip__sep {
|
||||
color: var(--color-text-5);
|
||||
}
|
||||
|
||||
.portal-statusstrip__volume {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-statusstrip__volume strong {
|
||||
color: var(--color-text-1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.portal-statusstrip__manage {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import { ProcessingStatusStrip } from "@portal/components/ProcessingStatusStrip";
|
||||
|
||||
const meta: Meta<typeof ProcessingStatusStrip> = {
|
||||
title: "Portal/Home/ProcessingStatusStrip",
|
||||
component: ProcessingStatusStrip,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "60rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ProcessingStatusStrip>;
|
||||
|
||||
/** Switch the Tier toolbar to compare the free meter vs the paid plan row. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Free tier pushed near its cap so the meter and upgrade nudge turn amber. */
|
||||
export const FreeNearCap: Story = {
|
||||
globals: { tier: "free" },
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/v1/home/kpis", async () => {
|
||||
await delay(80);
|
||||
return HttpResponse.json([{ value: "472 / 500" }]);
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Banner, Button, ProgressBar, Skeleton } from "@shared/components";
|
||||
import { TIER_INFO, useTier } from "@portal/contexts/TierContext";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import { fetchHomeKpis, type KpiEntry } from "@portal/api/home";
|
||||
import "@portal/components/ProcessingStatusStrip.css";
|
||||
|
||||
/**
|
||||
* Parses the free-tier "used / cap" KPI string (e.g. "247 / 500") into its
|
||||
* parts. The free meter is the headline KPI value rather than a separate
|
||||
* endpoint, so the strip reads it from the same `fetchHomeKpis` payload the
|
||||
* KPI cards use — no duplicate fetch, no second source of truth.
|
||||
*/
|
||||
function parseUsage(value: KpiEntry["value"]): {
|
||||
used: number;
|
||||
cap: number;
|
||||
} | null {
|
||||
const match = String(value).match(/([\d,]+)\s*\/\s*([\d,]+)/);
|
||||
if (!match) return null;
|
||||
const used = Number(match[1].replace(/,/g, ""));
|
||||
const cap = Number(match[2].replace(/,/g, ""));
|
||||
if (!Number.isFinite(used) || !Number.isFinite(cap) || cap <= 0) return null;
|
||||
return { used, cap };
|
||||
}
|
||||
|
||||
export function ProcessingStatusStrip() {
|
||||
const { tier } = useTier();
|
||||
const { setActiveView } = useView();
|
||||
const { data: kpis, loading } = useAsync<KpiEntry[]>(
|
||||
() => fetchHomeKpis(tier),
|
||||
[tier],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="portal-statusstrip" aria-busy>
|
||||
<Skeleton width="9rem" height="0.875rem" />
|
||||
<Skeleton height="0.5rem" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (tier === "free") {
|
||||
const usage = parseUsage(kpis?.[0]?.value ?? "");
|
||||
const used = usage?.used ?? 0;
|
||||
const cap = usage?.cap ?? 500;
|
||||
const ratio = cap > 0 ? used / cap : 0;
|
||||
const nearCap = ratio >= 0.8;
|
||||
|
||||
return (
|
||||
<Banner
|
||||
tone={nearCap ? "warning" : "neutral"}
|
||||
className="portal-statusstrip portal-statusstrip--free"
|
||||
action={
|
||||
nearCap ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setActiveView("usage")}
|
||||
>
|
||||
Upgrade
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="portal-statusstrip__free-row">
|
||||
<span className="portal-statusstrip__free-label">
|
||||
<strong>{used.toLocaleString()}</strong> / {cap.toLocaleString()}{" "}
|
||||
PDFs this month
|
||||
</span>
|
||||
<span className="portal-statusstrip__free-pct">
|
||||
{Math.round(ratio * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={ratio}
|
||||
thresholded
|
||||
label={`${used} of ${cap} PDFs used this month`}
|
||||
/>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
// Pro / enterprise: plan name + headline volume from the first KPI.
|
||||
const volume = kpis?.[0]?.value;
|
||||
return (
|
||||
<div className="portal-statusstrip portal-statusstrip--paid">
|
||||
<span className="portal-statusstrip__plan">
|
||||
<span
|
||||
className="portal-statusstrip__dot"
|
||||
style={{ background: TIER_INFO[tier].dotColor }}
|
||||
aria-hidden
|
||||
/>
|
||||
{TIER_INFO[tier].label}
|
||||
</span>
|
||||
<span className="portal-statusstrip__sep" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<span className="portal-statusstrip__volume">
|
||||
<strong>{volume ?? "—"}</strong> PDFs processed · last 30 days
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="portal-statusstrip__manage"
|
||||
onClick={() => setActiveView("usage")}
|
||||
>
|
||||
Manage plan
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,8 @@
|
||||
/* 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;
|
||||
/* The settings overlay hosts a full-bleed two-pane SettingsShell, so the
|
||||
modal frame contributes no padding of its own and lets the shell scroll. */
|
||||
.portal-settings .sui-modal__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.portal-settings__section {
|
||||
@@ -221,6 +212,13 @@
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* Label paired with a gating badge (e.g. "SCIM provisioning" + Enterprise). */
|
||||
.portal-settings__row-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* ── Workspace plan card ──────────────────────────────────────────────── */
|
||||
.portal-settings__plan {
|
||||
display: flex;
|
||||
|
||||
@@ -6,20 +6,49 @@ import {
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
SettingsShell,
|
||||
Skeleton,
|
||||
StatusBadge,
|
||||
Tabs,
|
||||
ToggleSwitch,
|
||||
type SelectOption,
|
||||
type TabItem,
|
||||
type SettingsNavSection,
|
||||
} from "@shared/components";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useTier, type Tier } 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 {
|
||||
fetchSettings,
|
||||
type ActiveSession,
|
||||
type BetaFeature,
|
||||
type SettingsSnapshot,
|
||||
} from "@portal/api/settings";
|
||||
import {
|
||||
UsersIcon,
|
||||
SunIcon,
|
||||
BellIcon,
|
||||
SettingsIcon,
|
||||
PoliciesIcon,
|
||||
InfrastructureIcon,
|
||||
SparklesIcon,
|
||||
} from "@portal/components/icons";
|
||||
import "@portal/components/SettingsModal.css";
|
||||
|
||||
type SettingsTab = "profile" | "preferences" | "workspace";
|
||||
type SettingsSection =
|
||||
| "profile"
|
||||
| "appearance"
|
||||
| "notifications"
|
||||
| "general"
|
||||
| "authentication"
|
||||
| "sessions"
|
||||
| "early-access";
|
||||
|
||||
/** Org-wide auth posture the Admin sections edit, mirrored into local state. */
|
||||
interface SecurityForm {
|
||||
mfaEnforced: boolean;
|
||||
ssoEnabled: boolean;
|
||||
scimEnabled: boolean;
|
||||
sessionTimeoutMins: number;
|
||||
}
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean;
|
||||
@@ -57,10 +86,55 @@ const NOTIFICATION_COPY: Record<
|
||||
},
|
||||
};
|
||||
|
||||
const TABS: TabItem<SettingsTab>[] = [
|
||||
{ key: "profile", label: "Profile" },
|
||||
{ key: "preferences", label: "Preferences" },
|
||||
{ key: "workspace", label: "Workspace" },
|
||||
const SECTION_LABEL: Record<SettingsSection, string> = {
|
||||
profile: "Profile",
|
||||
appearance: "Appearance",
|
||||
notifications: "Notifications",
|
||||
general: "General",
|
||||
authentication: "Authentication",
|
||||
sessions: "Active sessions",
|
||||
"early-access": "Early access",
|
||||
};
|
||||
|
||||
const NAV_SECTIONS: SettingsNavSection[] = [
|
||||
{
|
||||
title: "Account",
|
||||
items: [
|
||||
{ key: "profile", label: "Profile", icon: <UsersIcon size={16} /> },
|
||||
{ key: "appearance", label: "Appearance", icon: <SunIcon size={16} /> },
|
||||
{
|
||||
key: "notifications",
|
||||
label: "Notifications",
|
||||
icon: <BellIcon size={16} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Workspace",
|
||||
items: [
|
||||
{ key: "general", label: "General", icon: <SettingsIcon size={16} /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Admin",
|
||||
items: [
|
||||
{
|
||||
key: "authentication",
|
||||
label: "Authentication",
|
||||
icon: <PoliciesIcon size={16} />,
|
||||
},
|
||||
{
|
||||
key: "sessions",
|
||||
label: "Active sessions",
|
||||
icon: <InfrastructureIcon size={16} />,
|
||||
},
|
||||
{
|
||||
key: "early-access",
|
||||
label: "Early access",
|
||||
icon: <SparklesIcon size={16} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const THEME_OPTIONS: { value: Theme; label: string; hint: string }[] = [
|
||||
@@ -68,17 +142,24 @@ const THEME_OPTIONS: { value: Theme; label: string; hint: string }[] = [
|
||||
{ value: "dark", label: "Dark", hint: "Dim surfaces" },
|
||||
];
|
||||
|
||||
const SESSION_TIMEOUT_OPTIONS: SelectOption[] = [
|
||||
{ value: "60", label: "1 hour" },
|
||||
{ value: "240", label: "4 hours" },
|
||||
{ value: "480", label: "8 hours" },
|
||||
{ value: "720", label: "12 hours" },
|
||||
{ value: "1440", label: "24 hours" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Account settings as a portal-wide overlay. A grouped left-nav (Account /
|
||||
* Workspace / Admin) over a tier-aware snapshot that seeds editable local form
|
||||
* state. Save is a no-op for the demo — it closes — but the theme control
|
||||
* writes straight through to ThemeProvider so the change is real and visible.
|
||||
*/
|
||||
export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||
const { tier } = useTier();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [tab, setTab] = useState<SettingsTab>("profile");
|
||||
const [section, setSection] = useState<SettingsSection>("profile");
|
||||
|
||||
const { data: snapshot, loading } = useAsync<SettingsSnapshot>(
|
||||
() => fetchSettings(tier),
|
||||
@@ -95,6 +176,13 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||
const [notifications, setNotifications] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [security, setSecurity] = useState<SecurityForm>({
|
||||
mfaEnforced: false,
|
||||
ssoEnabled: false,
|
||||
scimEnabled: false,
|
||||
sessionTimeoutMins: 480,
|
||||
});
|
||||
const [betaToggles, setBetaToggles] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!snapshot) return;
|
||||
@@ -105,10 +193,19 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||
setNotifications(
|
||||
Object.fromEntries(snapshot.notifications.map((n) => [n.id, n.enabled])),
|
||||
);
|
||||
setSecurity({
|
||||
mfaEnforced: snapshot.security.mfaEnforced,
|
||||
ssoEnabled: snapshot.security.ssoEnabled,
|
||||
scimEnabled: snapshot.security.scimEnabled,
|
||||
sessionTimeoutMins: snapshot.security.sessionTimeoutMins,
|
||||
});
|
||||
setBetaToggles(
|
||||
Object.fromEntries(snapshot.betaFeatures.map((f) => [f.id, f.enabled])),
|
||||
);
|
||||
}, [snapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setTab("profile");
|
||||
if (open) setSection("profile");
|
||||
}, [open]);
|
||||
|
||||
const regionOptions = useMemo<SelectOption[]>(() => {
|
||||
@@ -129,36 +226,31 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width="lg"
|
||||
title="Settings"
|
||||
subtitle="Manage your profile, preferences, and workspace."
|
||||
width="xl"
|
||||
ariaLabel="Settings"
|
||||
className="portal-settings"
|
||||
footer={
|
||||
<>
|
||||
<span className="portal-settings__footer-note">
|
||||
Changes apply to this workspace.
|
||||
</span>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="gradient" onClick={onClose}>
|
||||
Save changes
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="portal-settings__tabs">
|
||||
<Tabs
|
||||
items={TABS}
|
||||
activeKey={tab}
|
||||
onChange={setTab}
|
||||
variant="underline"
|
||||
ariaLabel="Settings sections"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="portal-settings__panel">
|
||||
{tab === "profile" && (
|
||||
<SettingsShell
|
||||
sections={NAV_SECTIONS}
|
||||
activeKey={section}
|
||||
onSelect={(k) => setSection(k as SettingsSection)}
|
||||
title={SECTION_LABEL[section]}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<span className="portal-settings__footer-note">
|
||||
Changes apply to this workspace.
|
||||
</span>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="gradient" onClick={onClose}>
|
||||
Save changes
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{section === "profile" && (
|
||||
<ProfilePanel
|
||||
loading={isLoading}
|
||||
name={name}
|
||||
@@ -170,20 +262,22 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "preferences" && (
|
||||
<PreferencesPanel
|
||||
{section === "appearance" && (
|
||||
<AppearancePanel theme={theme} onTheme={setTheme} />
|
||||
)}
|
||||
|
||||
{section === "notifications" && (
|
||||
<NotificationsPanel
|
||||
loading={isLoading}
|
||||
notifications={notifications}
|
||||
order={snapshot?.notifications.map((n) => n.id) ?? []}
|
||||
onToggle={(id, value) =>
|
||||
setNotifications((prev) => ({ ...prev, [id]: value }))
|
||||
}
|
||||
theme={theme}
|
||||
onTheme={setTheme}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "workspace" && (
|
||||
{section === "general" && (
|
||||
<WorkspacePanel
|
||||
loading={isLoading}
|
||||
workspaceName={workspaceName}
|
||||
@@ -195,7 +289,35 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||
seats={snapshot?.workspace.seats}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{section === "authentication" && (
|
||||
<AuthenticationPanel
|
||||
loading={isLoading}
|
||||
tier={tier}
|
||||
security={security}
|
||||
onSecurity={(patch) => setSecurity((s) => ({ ...s, ...patch }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{section === "sessions" && (
|
||||
<SessionsPanel
|
||||
loading={isLoading}
|
||||
sessions={snapshot?.security.activeSessions ?? []}
|
||||
/>
|
||||
)}
|
||||
|
||||
{section === "early-access" && (
|
||||
<EarlyAccessPanel
|
||||
loading={isLoading}
|
||||
tier={tier}
|
||||
betaFeatures={snapshot?.betaFeatures ?? []}
|
||||
betaToggles={betaToggles}
|
||||
onBeta={(id, value) =>
|
||||
setBetaToggles((prev) => ({ ...prev, [id]: value }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SettingsShell>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -286,21 +408,13 @@ function ProfilePanel({
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Preferences */
|
||||
/* Appearance */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function PreferencesPanel({
|
||||
loading,
|
||||
notifications,
|
||||
order,
|
||||
onToggle,
|
||||
function AppearancePanel({
|
||||
theme,
|
||||
onTheme,
|
||||
}: {
|
||||
loading: boolean;
|
||||
notifications: Record<string, boolean>;
|
||||
order: string[];
|
||||
onToggle: (id: string, value: boolean) => void;
|
||||
theme: Theme;
|
||||
onTheme: (theme: Theme) => void;
|
||||
}) {
|
||||
@@ -308,7 +422,7 @@ function PreferencesPanel({
|
||||
<div className="portal-settings__section">
|
||||
<div className="portal-settings__group">
|
||||
<div className="portal-settings__group-head">
|
||||
<h3 className="portal-settings__group-title">Appearance</h3>
|
||||
<h3 className="portal-settings__group-title">Theme</h3>
|
||||
<p className="portal-settings__group-sub">
|
||||
Choose how the portal looks on this device.
|
||||
</p>
|
||||
@@ -345,10 +459,30 @@ function PreferencesPanel({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Notifications */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function NotificationsPanel({
|
||||
loading,
|
||||
notifications,
|
||||
order,
|
||||
onToggle,
|
||||
}: {
|
||||
loading: boolean;
|
||||
notifications: Record<string, boolean>;
|
||||
order: string[];
|
||||
onToggle: (id: string, value: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="portal-settings__section">
|
||||
<div className="portal-settings__group">
|
||||
<div className="portal-settings__group-head">
|
||||
<h3 className="portal-settings__group-title">Notifications</h3>
|
||||
<h3 className="portal-settings__group-title">Email notifications</h3>
|
||||
<p className="portal-settings__group-sub">
|
||||
Pick which events reach your inbox.
|
||||
</p>
|
||||
@@ -469,3 +603,233 @@ function WorkspacePanel({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Admin · Authentication */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function AuthenticationPanel({
|
||||
loading,
|
||||
tier,
|
||||
security,
|
||||
onSecurity,
|
||||
}: {
|
||||
loading: boolean;
|
||||
tier: Tier;
|
||||
security: SecurityForm;
|
||||
onSecurity: (patch: Partial<SecurityForm>) => void;
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="portal-settings__section">
|
||||
<Skeleton height="3rem" />
|
||||
<Skeleton height="3rem" />
|
||||
<Skeleton height="3rem" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// SSO/SCIM are enterprise capabilities; below it they render locked with a
|
||||
// badge rather than disappearing, so the upgrade path stays visible.
|
||||
const isEnterprise = tier === "enterprise";
|
||||
|
||||
return (
|
||||
<div className="portal-settings__section">
|
||||
<div className="portal-settings__group">
|
||||
<div className="portal-settings__group-head">
|
||||
<h3 className="portal-settings__group-title">Sign-in policy</h3>
|
||||
<p className="portal-settings__group-sub">
|
||||
Organisation-wide authentication controls.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="portal-settings__notifs">
|
||||
<div className="portal-settings__notif-row">
|
||||
<div className="portal-settings__notif-text">
|
||||
<strong>Enforce two-factor (MFA)</strong>
|
||||
<span>Require every member to complete MFA at sign-in.</span>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={security.mfaEnforced}
|
||||
onChange={(v) => onSecurity({ mfaEnforced: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="portal-settings__notif-row">
|
||||
<div className="portal-settings__notif-text">
|
||||
<span className="portal-settings__row-label">
|
||||
<strong>Single sign-on (SAML)</strong>
|
||||
{!isEnterprise && (
|
||||
<StatusBadge tone="info" size="sm" showDot={false}>
|
||||
Enterprise
|
||||
</StatusBadge>
|
||||
)}
|
||||
</span>
|
||||
<span>Federate sign-in through your identity provider.</span>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={isEnterprise && security.ssoEnabled}
|
||||
disabled={!isEnterprise}
|
||||
onChange={(v) => onSecurity({ ssoEnabled: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="portal-settings__notif-row">
|
||||
<div className="portal-settings__notif-text">
|
||||
<span className="portal-settings__row-label">
|
||||
<strong>SCIM provisioning</strong>
|
||||
{!isEnterprise && (
|
||||
<StatusBadge tone="info" size="sm" showDot={false}>
|
||||
Enterprise
|
||||
</StatusBadge>
|
||||
)}
|
||||
</span>
|
||||
<span>Sync members and roles from your directory.</span>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={isEnterprise && security.scimEnabled}
|
||||
disabled={!isEnterprise}
|
||||
onChange={(v) => onSecurity({ scimEnabled: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label="Session timeout"
|
||||
helperText="Members re-authenticate after this idle period."
|
||||
>
|
||||
<Select
|
||||
value={String(security.sessionTimeoutMins)}
|
||||
onChange={(e) =>
|
||||
onSecurity({ sessionTimeoutMins: Number(e.target.value) })
|
||||
}
|
||||
options={SESSION_TIMEOUT_OPTIONS}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Admin · Active sessions */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function SessionsPanel({
|
||||
loading,
|
||||
sessions,
|
||||
}: {
|
||||
loading: boolean;
|
||||
sessions: ActiveSession[];
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="portal-settings__section">
|
||||
<Skeleton height="3rem" />
|
||||
<Skeleton height="3rem" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="portal-settings__section">
|
||||
<div className="portal-settings__group">
|
||||
<div className="portal-settings__group-head">
|
||||
<h3 className="portal-settings__group-title">Active sessions</h3>
|
||||
<p className="portal-settings__group-sub">
|
||||
Devices currently signed in to this account.
|
||||
</p>
|
||||
</div>
|
||||
<div className="portal-settings__notifs">
|
||||
{sessions.map((s) => (
|
||||
<div key={s.id} className="portal-settings__notif-row">
|
||||
<div className="portal-settings__notif-text">
|
||||
<strong>{s.device}</strong>
|
||||
<span>
|
||||
{s.location} · {s.lastActive}
|
||||
</span>
|
||||
</div>
|
||||
{s.current ? (
|
||||
<StatusBadge tone="success" size="sm">
|
||||
This device
|
||||
</StatusBadge>
|
||||
) : (
|
||||
// TODO(backend): DELETE /v1/settings/sessions/{id}
|
||||
<Button variant="ghost" size="sm">
|
||||
Revoke
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Admin · Early access */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function EarlyAccessPanel({
|
||||
loading,
|
||||
tier,
|
||||
betaFeatures,
|
||||
betaToggles,
|
||||
onBeta,
|
||||
}: {
|
||||
loading: boolean;
|
||||
tier: Tier;
|
||||
betaFeatures: BetaFeature[];
|
||||
betaToggles: Record<string, boolean>;
|
||||
onBeta: (id: string, value: boolean) => void;
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="portal-settings__section">
|
||||
<Skeleton height="3rem" />
|
||||
<Skeleton height="3rem" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isEnterprise = tier === "enterprise";
|
||||
|
||||
return (
|
||||
<div className="portal-settings__section">
|
||||
<div className="portal-settings__group">
|
||||
<div className="portal-settings__group-head">
|
||||
<h3 className="portal-settings__group-title">Preview features</h3>
|
||||
<p className="portal-settings__group-sub">
|
||||
Opt into features still in preview.
|
||||
</p>
|
||||
</div>
|
||||
<div className="portal-settings__notifs">
|
||||
{betaFeatures.map((f) => {
|
||||
const locked = Boolean(f.enterpriseOnly) && !isEnterprise;
|
||||
return (
|
||||
<div key={f.id} className="portal-settings__notif-row">
|
||||
<div className="portal-settings__notif-text">
|
||||
<span className="portal-settings__row-label">
|
||||
<strong>{f.label}</strong>
|
||||
{locked && (
|
||||
<StatusBadge tone="info" size="sm" showDot={false}>
|
||||
Enterprise
|
||||
</StatusBadge>
|
||||
)}
|
||||
</span>
|
||||
<span>{f.description}</span>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={!locked && (betaToggles[f.id] ?? false)}
|
||||
disabled={locked}
|
||||
onChange={(v) => onBeta(f.id, v)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,24 +20,24 @@
|
||||
border-bottom: 1px solid var(--color-sidebar-divider);
|
||||
}
|
||||
|
||||
/* Editor "Stirling PDF" wordmark logo (theme-switched in Sidebar.tsx) */
|
||||
/* Stirling brand mark (theme-switched in Sidebar.tsx) + product wordmark */
|
||||
.portal-sidebar__brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.portal-sidebar__brand-logo {
|
||||
height: 1.5rem;
|
||||
.portal-sidebar__brand-mark {
|
||||
height: 1.375rem;
|
||||
width: auto;
|
||||
display: block;
|
||||
}
|
||||
/* TEMP: "portal" suffix styled to read as part of the wordmark */
|
||||
.portal-sidebar__logo-suffix {
|
||||
font-family: var(--font-brand);
|
||||
font-size: 1.125rem;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-3);
|
||||
color: var(--color-text-1);
|
||||
letter-spacing: 0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* App switcher (down-arrow → Portal / Editor) */
|
||||
|
||||
@@ -5,15 +5,16 @@ import { useTheme } from "@portal/contexts/ThemeContext";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import { fetchHomeKpis, type KpiEntry } from "@portal/api/home";
|
||||
import wordmarkLight from "@shared/assets/stirling-pdf-logo-light.svg";
|
||||
import wordmarkDark from "@shared/assets/stirling-pdf-logo-dark.svg";
|
||||
import markLight from "@shared/assets/stirling-mark-light.svg";
|
||||
import markDark from "@shared/assets/stirling-mark-dark.svg";
|
||||
import {
|
||||
HomeIcon,
|
||||
UsersIcon,
|
||||
SourcesIcon,
|
||||
PoliciesIcon,
|
||||
PipelinesIcon,
|
||||
DocumentsIcon,
|
||||
ComponentsIcon,
|
||||
InfrastructureIcon,
|
||||
UsageIcon,
|
||||
DocsIcon,
|
||||
@@ -22,10 +23,9 @@ import {
|
||||
} from "@portal/components/icons";
|
||||
import "@portal/components/Sidebar.css";
|
||||
|
||||
// TEMP app switcher. The editor is a separate Vite app — no shared shell yet
|
||||
// (see PORTAL_INTEGRATION_PLAN.md), so switching is a hard navigation: the
|
||||
// editor's own dev server in dev, the site root in prod. A standalone portal
|
||||
// deploy will later gate this behind a configured editor URL.
|
||||
// The editor is a separate Vite app with no shared shell, so switching apps is
|
||||
// a hard navigation — the editor's dev server in dev, the site root in prod.
|
||||
// A standalone portal deploy can gate this behind a configured editor URL.
|
||||
const EDITOR_URL = import.meta.env.DEV ? "http://localhost:5180/" : "/";
|
||||
|
||||
interface NavEntry {
|
||||
@@ -39,9 +39,12 @@ const GROUP_PRIMARY: NavEntry[] = [
|
||||
];
|
||||
|
||||
const GROUP_OPERATIONAL: NavEntry[] = [
|
||||
{ id: "users", label: "Users", icon: <UsersIcon /> },
|
||||
{ id: "sources", label: "Sources", icon: <SourcesIcon /> },
|
||||
{ id: "policies", label: "Policies", icon: <PoliciesIcon /> },
|
||||
{ id: "pipelines", label: "Pipelines", icon: <PipelinesIcon /> },
|
||||
{ id: "documents", label: "Documents", icon: <DocumentsIcon /> },
|
||||
{ id: "components", label: "Components", icon: <ComponentsIcon /> },
|
||||
];
|
||||
|
||||
const GROUP_PLATFORM: NavEntry[] = [
|
||||
@@ -132,11 +135,13 @@ export function Sidebar() {
|
||||
<div className="portal-sidebar__logo">
|
||||
<span className="portal-sidebar__brand">
|
||||
<img
|
||||
className="portal-sidebar__brand-logo"
|
||||
src={theme === "dark" ? wordmarkDark : wordmarkLight}
|
||||
alt="Stirling PDF"
|
||||
className="portal-sidebar__brand-mark"
|
||||
src={theme === "dark" ? markDark : markLight}
|
||||
alt="Stirling"
|
||||
/>
|
||||
<span className="portal-sidebar__logo-suffix">portal</span>
|
||||
<span className="portal-sidebar__logo-suffix">
|
||||
Stirling Processor
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<Dropdown.Root align="end" className="portal-sidebar__app-switch">
|
||||
@@ -160,7 +165,7 @@ export function Sidebar() {
|
||||
/>
|
||||
}
|
||||
>
|
||||
Portal
|
||||
Processor
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onSelect={() => {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { agentsFor } from "@portal/mocks/agents";
|
||||
import { AgentBuilderPanel } from "@portal/components/agent-builder/AgentBuilderPanel";
|
||||
|
||||
const PRO = agentsFor("pro");
|
||||
|
||||
const meta: Meta<typeof AgentBuilderPanel> = {
|
||||
title: "Portal/AgentBuilder/AgentBuilderPanel",
|
||||
component: AgentBuilderPanel,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "52rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AgentBuilderPanel>;
|
||||
|
||||
/** Published agent with enterprise governance unlocked. */
|
||||
export const Published: Story = {
|
||||
args: { agent: PRO[0], governanceUnlocked: true },
|
||||
};
|
||||
|
||||
/** Draft agent with governance locked (pro/free posture). */
|
||||
export const DraftLocked: Story = {
|
||||
args: { agent: PRO[2], governanceUnlocked: false },
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from "react";
|
||||
import { StatusBadge, Tabs, type TabItem } from "@shared/components";
|
||||
import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents";
|
||||
import { ScenariosPanel } from "@portal/components/agent-builder/ScenariosPanel";
|
||||
import { ToolsPanel } from "@portal/components/agent-builder/ToolsPanel";
|
||||
import { EvalsPanel } from "@portal/components/agent-builder/EvalsPanel";
|
||||
import { VersionsPanel } from "@portal/components/agent-builder/VersionsPanel";
|
||||
import "@portal/views/AgentBuilder.css";
|
||||
|
||||
type BuilderTab = "scenarios" | "tools" | "evals" | "versions";
|
||||
|
||||
interface AgentBuilderPanelProps {
|
||||
agent: Agent;
|
||||
/** Enterprise unlocks restricted-tools governance and deep version history. */
|
||||
governanceUnlocked: boolean;
|
||||
}
|
||||
|
||||
/** The selected agent's builder: header + tabbed Scenarios / Tools / Evals / Versions. */
|
||||
export function AgentBuilderPanel({
|
||||
agent,
|
||||
governanceUnlocked,
|
||||
}: AgentBuilderPanelProps) {
|
||||
const [tab, setTab] = useState<BuilderTab>("scenarios");
|
||||
|
||||
const tabs: TabItem<BuilderTab>[] = [
|
||||
{ key: "scenarios", label: "Scenarios", count: agent.scenarios.length },
|
||||
{ key: "tools", label: "Tools" },
|
||||
{
|
||||
key: "evals",
|
||||
label: "Evals",
|
||||
count: agent.evalsTotal > 0 ? agent.evalsTotal : undefined,
|
||||
},
|
||||
{ key: "versions", label: "Versions", count: agent.versions.length },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="portal-agents__builder">
|
||||
<header className="portal-agents__builder-head">
|
||||
<div>
|
||||
<h2 className="portal-agents__builder-title">{agent.name}</h2>
|
||||
<span className="portal-agents__builder-sub">{agent.role}</span>
|
||||
</div>
|
||||
<div className="portal-agents__builder-meta">
|
||||
<StatusBadge tone={AGENT_STATUS_TONE[agent.status]} size="sm">
|
||||
{agent.status}
|
||||
</StatusBadge>
|
||||
<code className="portal-agents__builder-version">
|
||||
{agent.version}
|
||||
</code>
|
||||
<code className="portal-agents__builder-model">{agent.model}</code>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Tabs<BuilderTab>
|
||||
items={tabs}
|
||||
activeKey={tab}
|
||||
onChange={setTab}
|
||||
variant="underline"
|
||||
ariaLabel="Agent builder sections"
|
||||
/>
|
||||
|
||||
{tab === "scenarios" && <ScenariosPanel agent={agent} />}
|
||||
{tab === "tools" && (
|
||||
<ToolsPanel agent={agent} governanceUnlocked={governanceUnlocked} />
|
||||
)}
|
||||
{tab === "evals" && <EvalsPanel agent={agent} />}
|
||||
{tab === "versions" && (
|
||||
<VersionsPanel agent={agent} historyUnlocked={governanceUnlocked} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { buildAgentsResponse } from "@portal/mocks/agents";
|
||||
import { AgentKpiStrip } from "@portal/components/agent-builder/AgentKpiStrip";
|
||||
|
||||
const meta: Meta<typeof AgentKpiStrip> = {
|
||||
title: "Portal/AgentBuilder/AgentKpiStrip",
|
||||
component: AgentKpiStrip,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AgentKpiStrip>;
|
||||
|
||||
export const Pro: Story = {
|
||||
args: { summary: buildAgentsResponse("pro").summary, loading: false },
|
||||
};
|
||||
|
||||
export const Enterprise: Story = {
|
||||
args: { summary: buildAgentsResponse("enterprise").summary, loading: false },
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: { summary: null, loading: true },
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { MetricCard, MetricStrip } from "@shared/components";
|
||||
import type { AgentsSummary } from "@portal/api/agents";
|
||||
|
||||
/**
|
||||
* KPI labels are product copy — they describe what each metric IS, not its
|
||||
* value — so the strip's structure stays stable across loading / ready states;
|
||||
* only values flow from the API.
|
||||
*/
|
||||
const KPI_LABELS = [
|
||||
"Active agents",
|
||||
"Avg eval pass rate",
|
||||
"Scenarios",
|
||||
"Latest published",
|
||||
] as const;
|
||||
|
||||
interface AgentKpiStripProps {
|
||||
summary: AgentsSummary | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function AgentKpiStrip({ summary, loading }: AgentKpiStripProps) {
|
||||
const values: (string | number)[] = summary
|
||||
? [
|
||||
summary.activeAgents,
|
||||
`${Math.round(summary.avgPassRate * 100)}%`,
|
||||
summary.totalScenarios,
|
||||
summary.latestPublished,
|
||||
]
|
||||
: ["—", "—", "—", "—"];
|
||||
|
||||
const descriptions: (string | undefined)[] = summary
|
||||
? [
|
||||
`${summary.totalAgents} total`,
|
||||
"across golden sets",
|
||||
"test cases",
|
||||
"fleet-wide",
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<MetricStrip>
|
||||
{KPI_LABELS.map((label, i) => (
|
||||
<MetricCard
|
||||
key={label}
|
||||
label={label}
|
||||
value={loading ? "—" : values[i]}
|
||||
description={loading ? undefined : descriptions[i]}
|
||||
/>
|
||||
))}
|
||||
</MetricStrip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { agentsFor } from "@portal/mocks/agents";
|
||||
import { AgentSelector } from "@portal/components/agent-builder/AgentSelector";
|
||||
|
||||
const AGENTS = agentsFor("enterprise");
|
||||
|
||||
const meta: Meta<typeof AgentSelector> = {
|
||||
title: "Portal/AgentBuilder/AgentSelector",
|
||||
component: AgentSelector,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "18rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AgentSelector>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { agents: AGENTS, selectedId: AGENTS[0].id, onSelect: () => {} },
|
||||
};
|
||||
|
||||
/** Clicking a row moves the selection — drives which builder is shown. */
|
||||
export const Interactive: Story = {
|
||||
render: () => {
|
||||
const [id, setId] = useState(AGENTS[0].id);
|
||||
return <AgentSelector agents={AGENTS} selectedId={id} onSelect={setId} />;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents";
|
||||
import { StatusBadge } from "@shared/components";
|
||||
import "@portal/views/AgentBuilder.css";
|
||||
|
||||
interface AgentSelectorProps {
|
||||
agents: Agent[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
/** Left-rail list of agents; the selected row drives which builder is shown. */
|
||||
export function AgentSelector({
|
||||
agents,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: AgentSelectorProps) {
|
||||
return (
|
||||
<nav className="portal-agents__selector" aria-label="Agents">
|
||||
{agents.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
className={
|
||||
"portal-agents__selector-item" +
|
||||
(a.id === selectedId ? " is-selected" : "")
|
||||
}
|
||||
aria-current={a.id === selectedId}
|
||||
onClick={() => onSelect(a.id)}
|
||||
>
|
||||
<span className="portal-agents__selector-main">
|
||||
<strong className="portal-agents__selector-name">{a.name}</strong>
|
||||
<span className="portal-agents__selector-role">{a.role}</span>
|
||||
</span>
|
||||
<span className="portal-agents__selector-meta">
|
||||
<StatusBadge tone={AGENT_STATUS_TONE[a.status]} size="sm">
|
||||
{a.status}
|
||||
</StatusBadge>
|
||||
<code className="portal-agents__selector-version">{a.version}</code>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Button } from "@shared/components";
|
||||
import { BootstrapDialog } from "@portal/components/agent-builder/BootstrapDialog";
|
||||
|
||||
const meta: Meta<typeof BootstrapDialog> = {
|
||||
title: "Portal/AgentBuilder/BootstrapDialog",
|
||||
component: BootstrapDialog,
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof BootstrapDialog>;
|
||||
|
||||
/** Open by default so the dialog is visible in the canvas. */
|
||||
export const Open: Story = {
|
||||
args: { open: true, onClose: () => {} },
|
||||
};
|
||||
|
||||
/** Toggled from a trigger, mirroring how the view drives it. */
|
||||
export const Triggered: Story = {
|
||||
render: () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div style={{ padding: "1.5rem" }}>
|
||||
<Button onClick={() => setOpen(true)}>Bootstrap from document</Button>
|
||||
<BootstrapDialog open={open} onClose={() => setOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Modal } from "@shared/components";
|
||||
import "@portal/views/AgentBuilder.css";
|
||||
|
||||
interface BootstrapDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a new agent from a sample document — the user drops one representative
|
||||
* file and the backend proposes scenarios and an extraction schema. Demo stub:
|
||||
* it captures the chosen file name locally and closes without provisioning.
|
||||
*/
|
||||
export function BootstrapDialog({ open, onClose }: BootstrapDialogProps) {
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
|
||||
function close() {
|
||||
onClose();
|
||||
setTimeout(() => setFileName(null), 200);
|
||||
}
|
||||
|
||||
function bootstrap() {
|
||||
// TODO(backend): POST /v1/agents/bootstrap (multipart sample document) —
|
||||
// infer a starter agent (scenarios + extraction schema) from the file.
|
||||
close();
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={close}
|
||||
width="md"
|
||||
title="Bootstrap from a document"
|
||||
subtitle="Seed a new agent from one representative file"
|
||||
footer={
|
||||
<div className="portal-agents__dialog-footer">
|
||||
<Button variant="ghost" size="sm" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={bootstrap} disabled={!fileName}>
|
||||
Bootstrap agent
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="portal-agents__bootstrap">
|
||||
<p className="portal-agents__bootstrap-lead">
|
||||
Drop a sample document and we'll propose scenarios and an
|
||||
extraction schema you can refine. Nothing is published until you
|
||||
review it.
|
||||
</p>
|
||||
<label className="portal-agents__dropzone">
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,.png,.jpg,.jpeg,.tiff"
|
||||
className="portal-agents__dropzone-input"
|
||||
onChange={(e) => setFileName(e.target.files?.[0]?.name ?? null)}
|
||||
/>
|
||||
<span className="portal-agents__dropzone-icon" aria-hidden>
|
||||
⇪
|
||||
</span>
|
||||
<span className="portal-agents__dropzone-text">
|
||||
{fileName ?? "Choose a sample document (PDF or image)"}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { agentsFor } from "@portal/mocks/agents";
|
||||
import { EvalsPanel } from "@portal/components/agent-builder/EvalsPanel";
|
||||
|
||||
const PRO = agentsFor("pro");
|
||||
const FREE = agentsFor("free");
|
||||
|
||||
const meta: Meta<typeof EvalsPanel> = {
|
||||
title: "Portal/AgentBuilder/EvalsPanel",
|
||||
component: EvalsPanel,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "44rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof EvalsPanel>;
|
||||
|
||||
/** High pass-rate agent — bar and pass-rate tile read green. */
|
||||
export const Passing: Story = {
|
||||
args: { agent: PRO[0] },
|
||||
};
|
||||
|
||||
/** KYC draft sits below the green band, so the pass-rate tile warns. */
|
||||
export const BelowTarget: Story = {
|
||||
args: { agent: PRO[2] },
|
||||
};
|
||||
|
||||
/** Free-tier agent has no golden set — the panel shows the upgrade gate. */
|
||||
export const NoGoldenSet: Story = {
|
||||
args: { agent: FREE[0] },
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
ProgressBar,
|
||||
StatTile,
|
||||
StatusBadge,
|
||||
Table,
|
||||
type TableColumn,
|
||||
} from "@shared/components";
|
||||
import type { Agent, EvalCase } from "@portal/api/agents";
|
||||
import "@portal/views/AgentBuilder.css";
|
||||
|
||||
interface EvalsPanelProps {
|
||||
agent: Agent;
|
||||
}
|
||||
|
||||
const COLUMNS: TableColumn<EvalCase>[] = [
|
||||
{ key: "name", header: "Eval case", render: (c) => c.name },
|
||||
{
|
||||
key: "result",
|
||||
header: "Result",
|
||||
render: (c) =>
|
||||
c.passing === null ? (
|
||||
<span className="portal-agents__muted">not run</span>
|
||||
) : (
|
||||
<StatusBadge tone={c.passing ? "success" : "danger"} size="sm">
|
||||
{c.passing ? "pass" : "fail"}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "latency",
|
||||
header: "Latency",
|
||||
align: "right",
|
||||
render: (c) => (
|
||||
<span className="portal-agents__mono">{c.latencyMs} ms</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
/** Golden-set pass-rate, the per-case results table, and a run affordance. */
|
||||
export function EvalsPanel({ agent }: EvalsPanelProps) {
|
||||
if (agent.evalsTotal === 0) {
|
||||
return (
|
||||
<div className="portal-agents__panel">
|
||||
<EmptyState
|
||||
title="No golden set yet"
|
||||
description="Evals turn your scenarios into a repeatable golden set. Upgrade to capture pass-rate over time and gate publishes on it."
|
||||
size="compact"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rate = agent.evalsPassing / agent.evalsTotal;
|
||||
|
||||
function runEvals() {
|
||||
// TODO(backend): POST /v1/agents/{id}/evals/run — kick off a golden-set
|
||||
// run, then poll for the updated case results.
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="portal-agents__panel">
|
||||
<div className="portal-agents__eval-head">
|
||||
<div className="portal-agents__stat-grid portal-agents__stat-grid--two">
|
||||
<StatTile
|
||||
label="Pass rate"
|
||||
value={`${Math.round(rate * 100)}%`}
|
||||
tone={rate >= 0.95 ? "success" : rate >= 0.8 ? "warning" : "danger"}
|
||||
/>
|
||||
<StatTile
|
||||
label="Cases passing"
|
||||
value={`${agent.evalsPassing} / ${agent.evalsTotal}`}
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={runEvals}>
|
||||
Run evals
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="portal-agents__bar-row">
|
||||
<div className="portal-agents__bar-head">
|
||||
<span>Golden-set pass rate</span>
|
||||
<strong>{Math.round(rate * 100)}%</strong>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={rate}
|
||||
color={rate >= 0.95 ? "var(--color-green)" : "var(--color-amber)"}
|
||||
label="Golden-set pass rate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Table<EvalCase>
|
||||
columns={COLUMNS}
|
||||
rows={agent.evalCases}
|
||||
rowKey={(c) => c.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { agentsFor } from "@portal/mocks/agents";
|
||||
import { ScenariosPanel } from "@portal/components/agent-builder/ScenariosPanel";
|
||||
|
||||
const AGENTS = agentsFor("pro");
|
||||
|
||||
const meta: Meta<typeof ScenariosPanel> = {
|
||||
title: "Portal/AgentBuilder/ScenariosPanel",
|
||||
component: ScenariosPanel,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "44rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ScenariosPanel>;
|
||||
|
||||
/** Contract Router ships three scenarios; the add-row stages a fourth locally. */
|
||||
export const Default: Story = {
|
||||
args: { agent: AGENTS[0] },
|
||||
};
|
||||
|
||||
/** An agent with a muted scenario (excluded from the eval run). */
|
||||
export const WithMutedScenario: Story = {
|
||||
args: { agent: AGENTS[1] },
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Chip,
|
||||
FormField,
|
||||
Input,
|
||||
StatusBadge,
|
||||
} from "@shared/components";
|
||||
import type { Agent, Scenario } from "@portal/api/agents";
|
||||
import "@portal/views/AgentBuilder.css";
|
||||
|
||||
interface ScenariosPanelProps {
|
||||
agent: Agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Named test cases describing expected behaviour. Edits live in local state —
|
||||
* the surface is mock-driven, so adding a row only stages it client-side until
|
||||
* the submit endpoint exists.
|
||||
*/
|
||||
export function ScenariosPanel({ agent }: ScenariosPanelProps) {
|
||||
// Seed from the agent and re-seed when the selection changes (key prop on the
|
||||
// builder forces a remount, so a plain useState initialiser is enough).
|
||||
const [scenarios, setScenarios] = useState<Scenario[]>(agent.scenarios);
|
||||
const [name, setName] = useState("");
|
||||
const [expectation, setExpectation] = useState("");
|
||||
|
||||
const canAdd = name.trim() !== "" && expectation.trim() !== "";
|
||||
|
||||
function addScenario() {
|
||||
if (!canAdd) return;
|
||||
const next: Scenario = {
|
||||
id: `sc-local-${Date.now()}`,
|
||||
name: name.trim(),
|
||||
expectation: expectation.trim(),
|
||||
enabled: true,
|
||||
};
|
||||
// TODO(backend): POST /v1/agents/{id}/scenarios { name, expectation } —
|
||||
// persist the scenario, then replace the optimistic row with the response.
|
||||
setScenarios((cur) => [...cur, next]);
|
||||
setName("");
|
||||
setExpectation("");
|
||||
}
|
||||
|
||||
function toggleEnabled(id: string) {
|
||||
setScenarios((cur) =>
|
||||
cur.map((s) => (s.id === id ? { ...s, enabled: !s.enabled } : s)),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="portal-agents__panel">
|
||||
<ul className="portal-agents__scenarios">
|
||||
{scenarios.map((s) => (
|
||||
<li key={s.id} className="portal-agents__scenario">
|
||||
<div className="portal-agents__scenario-text">
|
||||
<div className="portal-agents__scenario-head">
|
||||
<strong>{s.name}</strong>
|
||||
<StatusBadge
|
||||
tone={s.enabled ? "success" : "neutral"}
|
||||
size="sm"
|
||||
showDot={false}
|
||||
>
|
||||
{s.enabled ? "in eval" : "muted"}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<span className="portal-agents__scenario-expect">
|
||||
{s.expectation}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => toggleEnabled(s.id)}
|
||||
>
|
||||
{s.enabled ? "Mute" : "Enable"}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="portal-agents__scenario-add">
|
||||
<Chip tone="blue" size="sm">
|
||||
Add scenario
|
||||
</Chip>
|
||||
<div className="portal-agents__scenario-form">
|
||||
<FormField label="Name">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Compliance escalation"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Expected behaviour">
|
||||
<Input
|
||||
value={expectation}
|
||||
onChange={(e) => setExpectation(e.target.value)}
|
||||
placeholder="What the agent should do"
|
||||
/>
|
||||
</FormField>
|
||||
<Button size="sm" onClick={addScenario} disabled={!canAdd}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { agentsFor } from "@portal/mocks/agents";
|
||||
import { ToolsPanel } from "@portal/components/agent-builder/ToolsPanel";
|
||||
|
||||
const AGENTS = agentsFor("enterprise");
|
||||
|
||||
const meta: Meta<typeof ToolsPanel> = {
|
||||
title: "Portal/AgentBuilder/ToolsPanel",
|
||||
component: ToolsPanel,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "44rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ToolsPanel>;
|
||||
|
||||
/** Restricted mode with a deny list — the enterprise governance posture. */
|
||||
export const Restricted: Story = {
|
||||
args: { agent: AGENTS[0], governanceUnlocked: true },
|
||||
};
|
||||
|
||||
/** Broad access — every tool callable. */
|
||||
export const Broad: Story = {
|
||||
args: { agent: AGENTS[1], governanceUnlocked: true },
|
||||
};
|
||||
|
||||
/** Governance locked: the toggle is disabled and explains the upgrade. */
|
||||
export const GovernanceLocked: Story = {
|
||||
args: { agent: AGENTS[0], governanceUnlocked: false },
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState } from "react";
|
||||
import { Chip, ToggleSwitch } from "@shared/components";
|
||||
import { type Agent, type ToolMode, TOOL_CATALOGUE } from "@portal/api/agents";
|
||||
import "@portal/views/AgentBuilder.css";
|
||||
|
||||
interface ToolsPanelProps {
|
||||
agent: Agent;
|
||||
/** Restricted-tools governance is an enterprise capability. */
|
||||
governanceUnlocked: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool-access posture. `broad` grants every tool; `restricted` is allow-by-
|
||||
* default minus an explicit deny list, picked from the known tool catalogue.
|
||||
*/
|
||||
export function ToolsPanel({ agent, governanceUnlocked }: ToolsPanelProps) {
|
||||
const [mode, setMode] = useState<ToolMode>(agent.toolMode);
|
||||
const [denied, setDenied] = useState<string[]>(agent.deniedTools);
|
||||
|
||||
function setRestricted(on: boolean) {
|
||||
// TODO(backend): PATCH /v1/agents/{id}/tools { mode, deniedTools } —
|
||||
// persist the access posture.
|
||||
setMode(on ? "restricted" : "broad");
|
||||
}
|
||||
|
||||
function toggleDenied(tool: string) {
|
||||
setDenied((cur) =>
|
||||
cur.includes(tool) ? cur.filter((t) => t !== tool) : [...cur, tool],
|
||||
);
|
||||
}
|
||||
|
||||
const restricted = mode === "restricted";
|
||||
|
||||
return (
|
||||
<div className="portal-agents__panel">
|
||||
<div className="portal-agents__tool-mode">
|
||||
<ToggleSwitch
|
||||
checked={restricted}
|
||||
onChange={setRestricted}
|
||||
disabled={!governanceUnlocked}
|
||||
label="Restricted tool access"
|
||||
description={
|
||||
governanceUnlocked
|
||||
? "Allow every tool except the ones you deny below."
|
||||
: "Tool governance is available on the Enterprise plan."
|
||||
}
|
||||
/>
|
||||
<Chip tone={restricted ? "amber" : "green"} size="sm">
|
||||
{restricted ? "Restricted" : "Broad access"}
|
||||
</Chip>
|
||||
</div>
|
||||
|
||||
{restricted && (
|
||||
<div className="portal-agents__detail-section">
|
||||
<span className="portal-agents__detail-heading">Denied tools</span>
|
||||
<p className="portal-agents__hint">
|
||||
Selected tools are blocked. Everything else stays callable.
|
||||
</p>
|
||||
<div className="portal-agents__chips">
|
||||
{TOOL_CATALOGUE.map((tool) => {
|
||||
const isDenied = denied.includes(tool);
|
||||
return (
|
||||
<Chip
|
||||
key={tool}
|
||||
tone={isDenied ? "red" : "neutral"}
|
||||
size="sm"
|
||||
onClick={
|
||||
governanceUnlocked ? () => toggleDenied(tool) : undefined
|
||||
}
|
||||
>
|
||||
{isDenied ? "✕ " : ""}
|
||||
{tool}
|
||||
</Chip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { agentsFor } from "@portal/mocks/agents";
|
||||
import { VersionsPanel } from "@portal/components/agent-builder/VersionsPanel";
|
||||
|
||||
const PRO = agentsFor("pro");
|
||||
|
||||
const meta: Meta<typeof VersionsPanel> = {
|
||||
title: "Portal/AgentBuilder/VersionsPanel",
|
||||
component: VersionsPanel,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "44rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof VersionsPanel>;
|
||||
|
||||
/** Full history with rollback on prior published versions. */
|
||||
export const FullHistory: Story = {
|
||||
args: { agent: PRO[0], historyUnlocked: true },
|
||||
};
|
||||
|
||||
/** A draft current version offers a publish action. */
|
||||
export const DraftAwaitingPublish: Story = {
|
||||
args: { agent: PRO[2], historyUnlocked: true },
|
||||
};
|
||||
|
||||
/** History locked: only the current version shows, with an upgrade hint. */
|
||||
export const HistoryLocked: Story = {
|
||||
args: { agent: PRO[0], historyUnlocked: false },
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Button, StatusBadge } from "@shared/components";
|
||||
import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents";
|
||||
import "@portal/views/AgentBuilder.css";
|
||||
|
||||
interface VersionsPanelProps {
|
||||
agent: Agent;
|
||||
/** Deep version history is an enterprise capability; lower tiers see the gate. */
|
||||
historyUnlocked: boolean;
|
||||
}
|
||||
|
||||
/** Render an ISO timestamp as a short, locale-stable date. */
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/** Version history with publish / rollback actions per row. */
|
||||
export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) {
|
||||
// Without governance, only the current version is meaningful to show.
|
||||
const versions = historyUnlocked
|
||||
? agent.versions
|
||||
: agent.versions.slice(0, 1);
|
||||
const publishedExists = agent.versions.some((v) => v.status === "published");
|
||||
|
||||
function publish(version: string) {
|
||||
void version;
|
||||
// TODO(backend): POST /v1/agents/{id}/versions/{v}/publish — promote the
|
||||
// draft to published and demote the previous published version.
|
||||
}
|
||||
|
||||
function rollback(version: string) {
|
||||
void version;
|
||||
// TODO(backend): POST /v1/agents/{id}/versions/{v}/rollback — re-publish a
|
||||
// prior version as the active one.
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="portal-agents__panel">
|
||||
<ol className="portal-agents__versions">
|
||||
{versions.map((v) => {
|
||||
const isCurrent = v.version === agent.version;
|
||||
return (
|
||||
<li key={v.version} className="portal-agents__version">
|
||||
<div className="portal-agents__version-main">
|
||||
<div className="portal-agents__version-head">
|
||||
<code className="portal-agents__version-tag">
|
||||
{v.version}
|
||||
</code>
|
||||
<StatusBadge tone={AGENT_STATUS_TONE[v.status]} size="sm">
|
||||
{v.status}
|
||||
</StatusBadge>
|
||||
{isCurrent && (
|
||||
<StatusBadge tone="info" size="sm" showDot={false}>
|
||||
current
|
||||
</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<span className="portal-agents__version-note">{v.note}</span>
|
||||
<span className="portal-agents__version-meta">
|
||||
{formatDate(v.createdAt)} · {v.author}
|
||||
</span>
|
||||
</div>
|
||||
<div className="portal-agents__version-actions">
|
||||
{v.status === "draft" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => publish(v.version)}
|
||||
>
|
||||
Publish
|
||||
</Button>
|
||||
)}
|
||||
{v.status === "published" && !isCurrent && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => rollback(v.version)}
|
||||
>
|
||||
Roll back
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{!historyUnlocked && publishedExists && (
|
||||
<p className="portal-agents__hint">
|
||||
Full version history and rollback are available on the Enterprise
|
||||
plan.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { componentsFor } from "@portal/mocks/sdkComponents";
|
||||
import { ComponentCard } from "@portal/components/catalogue/ComponentCard";
|
||||
|
||||
const PRO = componentsFor("pro");
|
||||
const GA = PRO.find((c) => c.maturity === "ga")!;
|
||||
const BETA = PRO.find((c) => c.maturity === "beta")!;
|
||||
|
||||
const meta: Meta<typeof ComponentCard> = {
|
||||
title: "Portal/Components/ComponentCard",
|
||||
component: ComponentCard,
|
||||
parameters: { layout: "padded" },
|
||||
args: { component: GA, unlocked: true, onOpen: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ComponentCard>;
|
||||
|
||||
export const GeneralAvailability: Story = {};
|
||||
|
||||
export const Beta: Story = {
|
||||
args: { component: BETA },
|
||||
};
|
||||
|
||||
/** Sits above the tier — dimmed with a lock affordance. */
|
||||
export const Locked: Story = {
|
||||
args: { component: BETA, unlocked: false },
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Card, Chip, StatusBadge } from "@shared/components";
|
||||
import {
|
||||
type SdkComponent,
|
||||
MATURITY_META,
|
||||
formatPrice,
|
||||
} from "@portal/api/sdkComponents";
|
||||
import "@portal/views/Components.css";
|
||||
|
||||
interface ComponentCardProps {
|
||||
component: SdkComponent;
|
||||
/** False when the component sits above the active tier — renders locked. */
|
||||
unlocked: boolean;
|
||||
onOpen: (component: SdkComponent) => void;
|
||||
}
|
||||
|
||||
/** A single catalogue tile: name, maturity, description, price and frameworks. */
|
||||
export function ComponentCard({
|
||||
component,
|
||||
unlocked,
|
||||
onOpen,
|
||||
}: ComponentCardProps) {
|
||||
const maturity = MATURITY_META[component.maturity];
|
||||
|
||||
return (
|
||||
<Card
|
||||
interactive
|
||||
padding="default"
|
||||
className={"portal-components__card" + (unlocked ? "" : " is-locked")}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Open ${component.name} component`}
|
||||
onClick={() => onOpen(component)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpen(component);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="portal-components__card-head">
|
||||
<h3 className="portal-components__card-name">{component.name}</h3>
|
||||
<StatusBadge tone={maturity.tone} size="sm" showDot={false}>
|
||||
{maturity.label}
|
||||
</StatusBadge>
|
||||
{!unlocked && (
|
||||
<span className="portal-components__lock" aria-label="Locked">
|
||||
🔒
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="portal-components__card-desc">{component.description}</p>
|
||||
|
||||
<div className="portal-components__card-meta">
|
||||
<span className="portal-components__price">
|
||||
{formatPrice(component.pricing)}
|
||||
</span>
|
||||
<span className="portal-components__pkg">
|
||||
@stirling/{component.package}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="portal-components__frameworks">
|
||||
{component.frameworks.map((fw) => (
|
||||
<Chip key={fw} size="sm" tone="neutral">
|
||||
{fw}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { componentsFor } from "@portal/mocks/sdkComponents";
|
||||
import { ComponentDetailModal } from "@portal/components/catalogue/ComponentDetailModal";
|
||||
|
||||
const PRO = componentsFor("pro");
|
||||
const VIEWER = PRO.find((c) => c.id === "viewer")!;
|
||||
const TOOLKIT = PRO.find((c) => c.id === "toolkit")!;
|
||||
|
||||
const meta: Meta<typeof ComponentDetailModal> = {
|
||||
title: "Portal/Components/ComponentDetailModal",
|
||||
component: ComponentDetailModal,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: { component: VIEWER, unlocked: true, onClose: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ComponentDetailModal>;
|
||||
|
||||
export const Unlocked: Story = {};
|
||||
|
||||
/** Enterprise-only component opened on a lower tier — shows the upgrade nudge. */
|
||||
export const Locked: Story = {
|
||||
args: { component: TOOLKIT, unlocked: false },
|
||||
};
|
||||
|
||||
export const Closed: Story = {
|
||||
args: { component: null },
|
||||
};
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Chip,
|
||||
CodeBlock,
|
||||
Modal,
|
||||
StatTile,
|
||||
StatusBadge,
|
||||
Tabs,
|
||||
} from "@shared/components";
|
||||
import {
|
||||
type SdkComponent,
|
||||
MATURITY_META,
|
||||
formatPrice,
|
||||
} from "@portal/api/sdkComponents";
|
||||
import { ComponentPropsTable } from "@portal/components/catalogue/ComponentPropsTable";
|
||||
import "@portal/views/Components.css";
|
||||
|
||||
type DetailTab = "overview" | "code" | "props" | "pricing";
|
||||
|
||||
const TABS: { key: DetailTab; label: string }[] = [
|
||||
{ key: "overview", label: "Overview" },
|
||||
{ key: "code", label: "Code" },
|
||||
{ key: "props", label: "Props / API" },
|
||||
{ key: "pricing", label: "Pricing" },
|
||||
];
|
||||
|
||||
interface ComponentDetailModalProps {
|
||||
component: SdkComponent | null;
|
||||
/** False when the open component sits above the active tier. */
|
||||
unlocked: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail overlay for a catalogue component: a live-preview placeholder plus
|
||||
* Overview / Code / Props / Pricing tabs. Locked components swap the install
|
||||
* CTA for an upgrade nudge.
|
||||
*/
|
||||
export function ComponentDetailModal({
|
||||
component,
|
||||
unlocked,
|
||||
onClose,
|
||||
}: ComponentDetailModalProps) {
|
||||
const [tab, setTab] = useState<DetailTab>("overview");
|
||||
|
||||
// Reset to the first tab whenever a new component is opened.
|
||||
const open = component !== null;
|
||||
if (!component) {
|
||||
return (
|
||||
<Modal open={false} onClose={onClose} ariaLabel="Component detail" />
|
||||
);
|
||||
}
|
||||
|
||||
const maturity = MATURITY_META[component.maturity];
|
||||
const npm = `@stirling/${component.package}`;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
key={component.id}
|
||||
open={open}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
setTab("overview");
|
||||
}}
|
||||
width="xl"
|
||||
title={
|
||||
<span className="portal-components__modal-title">
|
||||
{component.name}
|
||||
<StatusBadge tone={maturity.tone} size="sm" showDot={false}>
|
||||
{maturity.label}
|
||||
</StatusBadge>
|
||||
</span>
|
||||
}
|
||||
subtitle={npm}
|
||||
footer={
|
||||
unlocked ? (
|
||||
<div className="portal-components__modal-footer">
|
||||
<span className="portal-components__price">
|
||||
{formatPrice(component.pricing)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
// TODO(backend): open the package quickstart / provision a
|
||||
// publishable key scoped to this component.
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
Add to project
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
accent="purple"
|
||||
// TODO(backend): route to the upgrade / contact-sales flow.
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
Upgrade to unlock
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{!unlocked && (
|
||||
<Banner
|
||||
tone="warning"
|
||||
title="Not available on your plan"
|
||||
description={`${component.name} is included from the ${component.minTier} plan. Upgrade to embed it.`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Live-preview sandbox — a styled placeholder until a real host mounts. */}
|
||||
<div className="portal-components__preview" aria-hidden>
|
||||
{/* TODO(backend)/host: mount the live <Sandbox> here, booting the
|
||||
component against a demo document and the dev's publishable key. */}
|
||||
<span className="portal-components__preview-badge">Live preview</span>
|
||||
<span className="portal-components__preview-note">
|
||||
Interactive sandbox renders here
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Tabs<DetailTab>
|
||||
className="portal-components__tabs"
|
||||
items={TABS}
|
||||
activeKey={tab}
|
||||
onChange={setTab}
|
||||
variant="underline"
|
||||
ariaLabel="Component detail sections"
|
||||
/>
|
||||
|
||||
<div className="portal-components__tab-body">
|
||||
{tab === "overview" && (
|
||||
<div className="portal-components__overview">
|
||||
<p className="portal-components__overview-desc">
|
||||
{component.description}
|
||||
</p>
|
||||
<div className="portal-components__frameworks">
|
||||
{component.frameworks.map((fw) => (
|
||||
<Chip key={fw} size="sm" tone="blue">
|
||||
{fw}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
<div className="portal-components__stat-grid">
|
||||
<StatTile label="Maturity" value={maturity.label} />
|
||||
<StatTile label="Price" value={formatPrice(component.pricing)} />
|
||||
<StatTile
|
||||
label="Free quota"
|
||||
value={
|
||||
component.pricing.freeQuota > 0
|
||||
? `${component.pricing.freeQuota.toLocaleString()} / mo`
|
||||
: "None"
|
||||
}
|
||||
/>
|
||||
<StatTile
|
||||
label="Embeds (30d)"
|
||||
value={component.embeds30d.toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "code" && (
|
||||
<div className="portal-components__code">
|
||||
<CodeBlock code={component.install} lang="bash" caption="Install" />
|
||||
<CodeBlock
|
||||
code={component.usage}
|
||||
lang="typescript"
|
||||
caption="Usage"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "props" && <ComponentPropsTable props={component.props} />}
|
||||
|
||||
{tab === "pricing" && (
|
||||
<div className="portal-components__pricing">
|
||||
<div className="portal-components__stat-grid">
|
||||
<StatTile
|
||||
label="Per action"
|
||||
value={formatPrice(component.pricing)}
|
||||
/>
|
||||
<StatTile label="Billed on" value={component.pricing.unit} />
|
||||
<StatTile
|
||||
label="Free quota"
|
||||
value={
|
||||
component.pricing.freeQuota > 0
|
||||
? `${component.pricing.freeQuota.toLocaleString()} / mo`
|
||||
: "None"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="portal-components__pricing-note">
|
||||
Metered per {component.pricing.unit}. Usage beyond the monthly
|
||||
free quota is billed to your account and itemised under Usage
|
||||
& Billing.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { componentsFor } from "@portal/mocks/sdkComponents";
|
||||
import { ComponentGrid } from "@portal/components/catalogue/ComponentGrid";
|
||||
|
||||
const meta: Meta<typeof ComponentGrid> = {
|
||||
title: "Portal/Components/ComponentGrid",
|
||||
component: ComponentGrid,
|
||||
parameters: { layout: "padded" },
|
||||
args: { components: componentsFor("pro"), tier: "pro", onOpen: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ComponentGrid>;
|
||||
|
||||
export const Pro: Story = {};
|
||||
|
||||
/** Free locks every paid component — the whole grid shows upgrade nudges. */
|
||||
export const Free: Story = {
|
||||
args: { components: componentsFor("free"), tier: "free" },
|
||||
};
|
||||
|
||||
/** Enterprise unlocks the enterprise-only Beta components. */
|
||||
export const Enterprise: Story = {
|
||||
args: { components: componentsFor("enterprise"), tier: "enterprise" },
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { type SdkComponent, isUnlocked } from "@portal/api/sdkComponents";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import { ComponentCard } from "@portal/components/catalogue/ComponentCard";
|
||||
import "@portal/views/Components.css";
|
||||
|
||||
interface ComponentGridProps {
|
||||
components: SdkComponent[];
|
||||
tier: Tier;
|
||||
onOpen: (component: SdkComponent) => void;
|
||||
}
|
||||
|
||||
/** Responsive grid of catalogue cards; locks components above the tier. */
|
||||
export function ComponentGrid({
|
||||
components,
|
||||
tier,
|
||||
onOpen,
|
||||
}: ComponentGridProps) {
|
||||
return (
|
||||
<div className="portal-components__grid">
|
||||
{components.map((c) => (
|
||||
<ComponentCard
|
||||
key={c.id}
|
||||
component={c}
|
||||
unlocked={isUnlocked(c, tier)}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { componentsFor } from "@portal/mocks/sdkComponents";
|
||||
import { ComponentPropsTable } from "@portal/components/catalogue/ComponentPropsTable";
|
||||
|
||||
const VIEWER = componentsFor("pro").find((c) => c.id === "viewer")!;
|
||||
|
||||
const meta: Meta<typeof ComponentPropsTable> = {
|
||||
title: "Portal/Components/ComponentPropsTable",
|
||||
component: ComponentPropsTable,
|
||||
parameters: { layout: "padded" },
|
||||
args: { props: VIEWER.props },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ComponentPropsTable>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useMemo } from "react";
|
||||
import { Chip, Table, type TableColumn } from "@shared/components";
|
||||
import type { ComponentProp } from "@portal/api/sdkComponents";
|
||||
import "@portal/views/Components.css";
|
||||
|
||||
interface ComponentPropsTableProps {
|
||||
props: ComponentProp[];
|
||||
}
|
||||
|
||||
/** Small Props/API reference shown under the detail modal's Props tab. */
|
||||
export function ComponentPropsTable({ props: rows }: ComponentPropsTableProps) {
|
||||
const columns = useMemo<TableColumn<ComponentProp>[]>(
|
||||
() => [
|
||||
{
|
||||
key: "name",
|
||||
header: "Prop",
|
||||
render: (p) => (
|
||||
<span className="portal-components__prop-name">{p.name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
header: "Type",
|
||||
render: (p) => (
|
||||
<code className="portal-components__prop-type">{p.type}</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "required",
|
||||
header: "Required",
|
||||
render: (p) =>
|
||||
p.required ? (
|
||||
<Chip size="sm" tone="amber">
|
||||
required
|
||||
</Chip>
|
||||
) : (
|
||||
<span className="portal-components__muted">optional</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
header: "Description",
|
||||
render: (p) => (
|
||||
<span className="portal-components__prop-desc">{p.description}</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Table<ComponentProp>
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(p) => p.name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { MetricCard, MetricStrip } from "@shared/components";
|
||||
import type { ComponentsResponse } from "@portal/api/sdkComponents";
|
||||
|
||||
/**
|
||||
* Labels are product copy — they describe what each metric IS, not its value,
|
||||
* so the strip's structure stays stable across loading / empty / ready states.
|
||||
* Only values flow from the API.
|
||||
*/
|
||||
const KPI_LABELS = [
|
||||
"Components GA",
|
||||
"In beta",
|
||||
"Embeds this month",
|
||||
"Component spend (MTD)",
|
||||
] as const;
|
||||
|
||||
interface ComponentsSummaryStripProps {
|
||||
data: ComponentsResponse | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function ComponentsSummaryStrip({
|
||||
data,
|
||||
loading,
|
||||
}: ComponentsSummaryStripProps) {
|
||||
const s = loading ? undefined : data?.summary;
|
||||
const values: (string | number)[] = [
|
||||
s?.gaCount ?? "—",
|
||||
s?.betaCount ?? "—",
|
||||
s ? s.embedsThisMonth.toLocaleString() : "—",
|
||||
s ? `$${s.spendThisMonth.toLocaleString()}` : "—",
|
||||
];
|
||||
|
||||
return (
|
||||
<MetricStrip>
|
||||
{KPI_LABELS.map((label, i) => (
|
||||
<MetricCard key={label} label={label} value={values[i]} />
|
||||
))}
|
||||
</MetricStrip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { documentsFor } from "@portal/mocks/documents";
|
||||
import { DocumentAudit } from "@portal/components/documents/DocumentAudit";
|
||||
import "@portal/views/Documents.css";
|
||||
|
||||
const DOC = documentsFor("pro")[0];
|
||||
|
||||
const meta: Meta<typeof DocumentAudit> = {
|
||||
title: "Portal/Documents/DocumentAudit",
|
||||
component: DocumentAudit,
|
||||
parameters: { layout: "padded" },
|
||||
args: { doc: DOC },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "36rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DocumentAudit>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A document whose lifecycle reached approval. */
|
||||
export const Approved: Story = {
|
||||
args: { doc: documentsFor("pro").find((d) => d.status === "processed")! },
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { StatusBadge } from "@shared/components";
|
||||
import {
|
||||
DOC_AUDIT_LABEL,
|
||||
DOC_AUDIT_TONE,
|
||||
type ReviewDocument,
|
||||
} from "@portal/api/documents";
|
||||
|
||||
/** Lifecycle timeline for a single document, oldest first. */
|
||||
export function DocumentAudit({ doc }: { doc: ReviewDocument }) {
|
||||
if (doc.audit.length === 0) {
|
||||
return <p className="portal-documents__muted">No events recorded yet.</p>;
|
||||
}
|
||||
return (
|
||||
<ol className="portal-documents__timeline">
|
||||
{doc.audit.map((event) => (
|
||||
<li key={event.id} className="portal-documents__timeline-item">
|
||||
<span className="portal-documents__timeline-dot" aria-hidden />
|
||||
<div className="portal-documents__timeline-body">
|
||||
<div className="portal-documents__timeline-head">
|
||||
<StatusBadge tone={DOC_AUDIT_TONE[event.kind]} size="sm">
|
||||
{DOC_AUDIT_LABEL[event.kind]}
|
||||
</StatusBadge>
|
||||
<span className="portal-documents__timeline-time">
|
||||
{event.time}
|
||||
</span>
|
||||
</div>
|
||||
<p className="portal-documents__timeline-detail">{event.detail}</p>
|
||||
<span className="portal-documents__timeline-actor">
|
||||
{event.actor}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { documentsFor } from "@portal/mocks/documents";
|
||||
import { DocumentDrawer } from "@portal/components/documents/DocumentDrawer";
|
||||
import "@portal/views/Documents.css";
|
||||
|
||||
const ALL = documentsFor("enterprise");
|
||||
const NON_SENSITIVE = ALL.find((d) => !d.sensitive)!;
|
||||
const SENSITIVE = ALL.find((d) => d.sensitive)!;
|
||||
|
||||
const meta: Meta<typeof DocumentDrawer> = {
|
||||
title: "Portal/Documents/DocumentDrawer",
|
||||
component: DocumentDrawer,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: { onClose: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DocumentDrawer>;
|
||||
|
||||
/** Standard document — all sub-tabs visible, content shown. */
|
||||
export const Default: Story = {
|
||||
args: { doc: NON_SENSITIVE },
|
||||
};
|
||||
|
||||
/**
|
||||
* Sensitive document — opens with the zero-standing-access banner; the
|
||||
* Extractions tab stays masked until access is requested. On the enterprise
|
||||
* tier the banner carries the four-eyes note.
|
||||
*/
|
||||
export const Sensitive: Story = {
|
||||
args: { doc: SENSITIVE },
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Drawer, StatusBadge, Tabs, type TabItem } from "@shared/components";
|
||||
import {
|
||||
DOCUMENT_STATUS_LABEL,
|
||||
DOCUMENT_STATUS_TONE,
|
||||
type ReviewDocument,
|
||||
} from "@portal/api/documents";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { ELEVATION_WINDOW_SECONDS } from "@portal/components/documents/format";
|
||||
import { DocumentOverview } from "@portal/components/documents/DocumentOverview";
|
||||
import { DocumentExtractions } from "@portal/components/documents/DocumentExtractions";
|
||||
import { DocumentAudit } from "@portal/components/documents/DocumentAudit";
|
||||
import { ElevationBanner } from "@portal/components/documents/ElevationBanner";
|
||||
|
||||
type SubTab = "overview" | "extractions" | "audit";
|
||||
|
||||
const SUB_TABS: TabItem<SubTab>[] = [
|
||||
{ key: "overview", label: "Overview" },
|
||||
{ key: "extractions", label: "Extractions" },
|
||||
{ key: "audit", label: "Audit" },
|
||||
];
|
||||
|
||||
interface DocumentDrawerProps {
|
||||
/** Selected document, or null when the drawer is closed. */
|
||||
doc: ReviewDocument | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail panel for a queued document. Sub-tabs split overview, extracted
|
||||
* fields, and the audit timeline. Sensitive documents gate their content
|
||||
* behind a client-side timed elevation; enterprise adds a four-eyes note.
|
||||
*/
|
||||
export function DocumentDrawer({ doc, onClose }: DocumentDrawerProps) {
|
||||
const { tier } = useTier();
|
||||
const fourEyes = tier === "enterprise";
|
||||
|
||||
const [tab, setTab] = useState<SubTab>("overview");
|
||||
// Seconds remaining on the active elevation grant; null means no grant.
|
||||
const [secondsLeft, setSecondsLeft] = useState<number | null>(null);
|
||||
|
||||
// Reset tab and any active grant whenever a different document opens — a
|
||||
// grant is scoped to the document it was requested for.
|
||||
useEffect(() => {
|
||||
setTab("overview");
|
||||
setSecondsLeft(null);
|
||||
}, [doc?.id]);
|
||||
|
||||
// Tick the countdown down to expiry, then drop the grant.
|
||||
useEffect(() => {
|
||||
if (secondsLeft === null) return;
|
||||
if (secondsLeft <= 0) {
|
||||
setSecondsLeft(null);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => setSecondsLeft((s) => (s ?? 1) - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [secondsLeft]);
|
||||
|
||||
if (!doc) return null;
|
||||
|
||||
function requestAccess() {
|
||||
// TODO(backend): POST /v1/documents/{id}/elevation — request a time-boxed
|
||||
// grant (and, on enterprise, trigger the four-eyes peer notification).
|
||||
// The grant + countdown are simulated client-side until that exists.
|
||||
setSecondsLeft(ELEVATION_WINDOW_SECONDS);
|
||||
}
|
||||
|
||||
const unlocked = secondsLeft !== null;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open
|
||||
onClose={onClose}
|
||||
width="lg"
|
||||
title={doc.name}
|
||||
subtitle={`${doc.type} · ${doc.source}`}
|
||||
>
|
||||
<div className="portal-documents__drawer">
|
||||
<div className="portal-documents__drawer-status">
|
||||
<StatusBadge tone={DOCUMENT_STATUS_TONE[doc.status]} size="sm">
|
||||
{DOCUMENT_STATUS_LABEL[doc.status]}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
|
||||
{doc.sensitive && (
|
||||
<ElevationBanner
|
||||
secondsLeft={secondsLeft}
|
||||
fourEyes={fourEyes}
|
||||
onRequest={requestAccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tabs<SubTab>
|
||||
items={SUB_TABS}
|
||||
activeKey={tab}
|
||||
onChange={setTab}
|
||||
variant="underline"
|
||||
ariaLabel="Document detail sections"
|
||||
/>
|
||||
|
||||
<div className="portal-documents__drawer-panel">
|
||||
{tab === "overview" && <DocumentOverview doc={doc} />}
|
||||
{tab === "extractions" && (
|
||||
<DocumentExtractions doc={doc} unlocked={unlocked} />
|
||||
)}
|
||||
{tab === "audit" && <DocumentAudit doc={doc} />}
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { documentsFor } from "@portal/mocks/documents";
|
||||
import { DocumentExtractions } from "@portal/components/documents/DocumentExtractions";
|
||||
import "@portal/views/Documents.css";
|
||||
|
||||
const ALL = documentsFor("enterprise");
|
||||
const NON_SENSITIVE = ALL.find((d) => !d.sensitive)!;
|
||||
const SENSITIVE = ALL.find((d) => d.sensitive)!;
|
||||
const NO_AMOUNT = ALL.find((d) =>
|
||||
d.extractions.some((e) => e.confidence === 0),
|
||||
)!;
|
||||
|
||||
const meta: Meta<typeof DocumentExtractions> = {
|
||||
title: "Portal/Documents/DocumentExtractions",
|
||||
component: DocumentExtractions,
|
||||
parameters: { layout: "padded" },
|
||||
args: { doc: NON_SENSITIVE, unlocked: false },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "36rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DocumentExtractions>;
|
||||
|
||||
/** Per-field table with mixed confidence tones. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A field that failed to extract sits at 0% confidence. */
|
||||
export const LowConfidence: Story = {
|
||||
args: { doc: NO_AMOUNT },
|
||||
};
|
||||
|
||||
/** Sensitive doc with no active grant — content stays masked. */
|
||||
export const Masked: Story = {
|
||||
args: { doc: SENSITIVE, unlocked: false },
|
||||
};
|
||||
|
||||
/** Same sensitive doc once a timed elevation is active. */
|
||||
export const Unlocked: Story = {
|
||||
args: { doc: SENSITIVE, unlocked: true },
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { StatusBadge, Table, type TableColumn } from "@shared/components";
|
||||
import { type Extraction, type ReviewDocument } from "@portal/api/documents";
|
||||
import {
|
||||
confidencePct,
|
||||
confidenceTone,
|
||||
} from "@portal/components/documents/format";
|
||||
|
||||
const cols: TableColumn<Extraction>[] = [
|
||||
{
|
||||
key: "field",
|
||||
header: "Field",
|
||||
render: (e) => <span className="portal-documents__field">{e.field}</span>,
|
||||
},
|
||||
{
|
||||
key: "value",
|
||||
header: "Value",
|
||||
render: (e) => <span className="portal-documents__mono">{e.value}</span>,
|
||||
},
|
||||
{
|
||||
key: "confidence",
|
||||
header: "Confidence",
|
||||
align: "right",
|
||||
width: "7rem",
|
||||
render: (e) => (
|
||||
<StatusBadge
|
||||
tone={confidenceTone(e.confidence)}
|
||||
size="sm"
|
||||
showDot={false}
|
||||
>
|
||||
{confidencePct(e.confidence)}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
interface DocumentExtractionsProps {
|
||||
doc: ReviewDocument;
|
||||
/**
|
||||
* Whether sensitive content may be shown. A sensitive doc renders its fields
|
||||
* only once a timed elevation is active; the gate UI itself lives in the
|
||||
* drawer so it can sit above all sub-tabs.
|
||||
*/
|
||||
unlocked: boolean;
|
||||
}
|
||||
|
||||
/** Per-field extraction table, gated behind elevation for sensitive docs. */
|
||||
export function DocumentExtractions({
|
||||
doc,
|
||||
unlocked,
|
||||
}: DocumentExtractionsProps) {
|
||||
if (doc.sensitive && !unlocked) {
|
||||
return (
|
||||
<div className="portal-documents__masked">
|
||||
<span className="portal-documents__masked-icon" aria-hidden>
|
||||
🔒
|
||||
</span>
|
||||
<p className="portal-documents__masked-text">
|
||||
Extracted fields are hidden. Request timed access to view this
|
||||
document's content.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table<Extraction>
|
||||
columns={cols}
|
||||
rows={doc.extractions}
|
||||
rowKey={(e) => e.field}
|
||||
empty="No fields were extracted from this document."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { documentsFor } from "@portal/mocks/documents";
|
||||
import { DocumentOverview } from "@portal/components/documents/DocumentOverview";
|
||||
import "@portal/views/Documents.css";
|
||||
|
||||
const DOC = documentsFor("pro")[0];
|
||||
|
||||
const meta: Meta<typeof DocumentOverview> = {
|
||||
title: "Portal/Documents/DocumentOverview",
|
||||
component: DocumentOverview,
|
||||
parameters: { layout: "padded" },
|
||||
args: { doc: DOC },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "36rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DocumentOverview>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { StatTile } from "@shared/components";
|
||||
import {
|
||||
DOCUMENT_STATUS_LABEL,
|
||||
type ReviewDocument,
|
||||
} from "@portal/api/documents";
|
||||
import { confidencePct } from "@portal/components/documents/format";
|
||||
|
||||
/** Key fields for the selected document — status, source, confidence. */
|
||||
export function DocumentOverview({ doc }: { doc: ReviewDocument }) {
|
||||
return (
|
||||
<div className="portal-documents__overview">
|
||||
<div className="portal-documents__stat-grid">
|
||||
<StatTile label="Status" value={DOCUMENT_STATUS_LABEL[doc.status]} />
|
||||
<StatTile label="Type" value={doc.type} />
|
||||
<StatTile label="Confidence" value={confidencePct(doc.confidence)} />
|
||||
<StatTile label="Fields extracted" value={doc.fieldsExtracted} />
|
||||
<StatTile label="Source" value={doc.source} />
|
||||
<StatTile label="Received" value={doc.time} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { summaryFor } from "@portal/mocks/documents";
|
||||
import { DocumentsSummaryStrip } from "@portal/components/documents/DocumentsSummaryStrip";
|
||||
import "@portal/views/Documents.css";
|
||||
|
||||
const meta: Meta<typeof DocumentsSummaryStrip> = {
|
||||
title: "Portal/Documents/DocumentsSummaryStrip",
|
||||
component: DocumentsSummaryStrip,
|
||||
parameters: { layout: "padded" },
|
||||
args: { summary: summaryFor("enterprise"), loading: false },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "78rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DocumentsSummaryStrip>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: { summary: null, loading: true },
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { MetricCard, MetricStrip, Skeleton } from "@shared/components";
|
||||
import type { DocumentsSummary } from "@portal/api/documents";
|
||||
import { confidencePct } from "@portal/components/documents/format";
|
||||
|
||||
interface DocumentsSummaryStripProps {
|
||||
summary: DocumentsSummary | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/** KPI strip across the top of the review queue. */
|
||||
export function DocumentsSummaryStrip({
|
||||
summary,
|
||||
loading,
|
||||
}: DocumentsSummaryStripProps) {
|
||||
if (loading && !summary) {
|
||||
return (
|
||||
<MetricStrip>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} height="5.5rem" />
|
||||
))}
|
||||
</MetricStrip>
|
||||
);
|
||||
}
|
||||
if (!summary) return null;
|
||||
|
||||
return (
|
||||
<MetricStrip>
|
||||
<MetricCard
|
||||
label="In queue"
|
||||
value={summary.totalInQueue.toLocaleString()}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Needs review"
|
||||
value={summary.needsReview.toLocaleString()}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Avg confidence"
|
||||
value={confidencePct(summary.avgConfidence)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Processed today"
|
||||
value={summary.processedToday.toLocaleString()}
|
||||
/>
|
||||
</MetricStrip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ElevationBanner } from "@portal/components/documents/ElevationBanner";
|
||||
import { ELEVATION_WINDOW_SECONDS } from "@portal/components/documents/format";
|
||||
import "@portal/views/Documents.css";
|
||||
|
||||
const meta: Meta<typeof ElevationBanner> = {
|
||||
title: "Portal/Documents/ElevationBanner",
|
||||
component: ElevationBanner,
|
||||
parameters: { layout: "padded" },
|
||||
args: { secondsLeft: null, fourEyes: false, onRequest: () => {} },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "40rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ElevationBanner>;
|
||||
|
||||
/** No grant yet — offers the request affordance. */
|
||||
export const Locked: Story = {};
|
||||
|
||||
/** No grant, enterprise four-eyes note. */
|
||||
export const LockedFourEyes: Story = {
|
||||
args: { fourEyes: true },
|
||||
};
|
||||
|
||||
/** Active grant counting down. */
|
||||
export const Granted: Story = {
|
||||
args: { secondsLeft: ELEVATION_WINDOW_SECONDS - 1 },
|
||||
};
|
||||
|
||||
export const GrantedFourEyes: Story = {
|
||||
args: { secondsLeft: ELEVATION_WINDOW_SECONDS - 1, fourEyes: true },
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Banner, Button } from "@shared/components";
|
||||
import { formatCountdown } from "@portal/components/documents/format";
|
||||
|
||||
interface ElevationBannerProps {
|
||||
/** Seconds left on the active grant, or null when no grant is active. */
|
||||
secondsLeft: number | null;
|
||||
/** True for tiers with the four-eyes elevation flow (enterprise). */
|
||||
fourEyes: boolean;
|
||||
onRequest: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero-standing-access affordance for a sensitive document. With no active
|
||||
* grant it offers a "Request access" button; once granted it counts down the
|
||||
* remaining window. The grant is client-side only — see the view for the
|
||||
* backend TODO.
|
||||
*/
|
||||
export function ElevationBanner({
|
||||
secondsLeft,
|
||||
fourEyes,
|
||||
onRequest,
|
||||
}: ElevationBannerProps) {
|
||||
if (secondsLeft !== null) {
|
||||
return (
|
||||
<Banner
|
||||
tone="success"
|
||||
icon={<span aria-hidden>⏱</span>}
|
||||
title={`Access expires in ${formatCountdown(secondsLeft)}`}
|
||||
description={
|
||||
fourEyes
|
||||
? "Temporary grant — a peer reviewer was notified (four-eyes)."
|
||||
: "Temporary grant — access is logged and time-boxed."
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Banner
|
||||
tone="warning"
|
||||
icon={<span aria-hidden>🔒</span>}
|
||||
title="Sensitive document"
|
||||
description={
|
||||
fourEyes
|
||||
? "Content is gated by zero-standing-access. Requesting starts a time-boxed grant and notifies a peer reviewer (four-eyes)."
|
||||
: "Content is gated by zero-standing-access. Requesting starts a time-boxed grant."
|
||||
}
|
||||
action={
|
||||
<Button size="sm" onClick={onRequest}>
|
||||
Request access
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import { ReviewQueue } from "@portal/components/documents/ReviewQueue";
|
||||
import "@portal/views/Documents.css";
|
||||
|
||||
const meta: Meta<typeof ReviewQueue> = {
|
||||
title: "Portal/Documents/ReviewQueue",
|
||||
component: ReviewQueue,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "78rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ReviewQueue>;
|
||||
|
||||
/** Live queue served by the default MSW handler (drives off the toolbar tier). */
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Loading: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/v1/documents", async () => {
|
||||
await delay("infinite");
|
||||
return HttpResponse.json({ summary: {}, documents: [] });
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/v1/documents", () =>
|
||||
HttpResponse.json({
|
||||
summary: {
|
||||
totalInQueue: 0,
|
||||
needsReview: 0,
|
||||
avgConfidence: 0,
|
||||
processedToday: 0,
|
||||
},
|
||||
documents: [],
|
||||
}),
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { EmptyState, Skeleton, Tabs, type TabItem } from "@shared/components";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
import {
|
||||
fetchDocuments,
|
||||
type DocumentStatus,
|
||||
type DocumentsResponse,
|
||||
type ReviewDocument,
|
||||
} from "@portal/api/documents";
|
||||
import { DocumentsSummaryStrip } from "@portal/components/documents/DocumentsSummaryStrip";
|
||||
import { ReviewQueueTable } from "@portal/components/documents/ReviewQueueTable";
|
||||
import { DocumentDrawer } from "@portal/components/documents/DocumentDrawer";
|
||||
|
||||
type QueueFilter = "all" | "needs-review" | "processed" | "archived";
|
||||
|
||||
/** Which document statuses each filter pill admits. */
|
||||
const FILTER_STATUSES: Record<QueueFilter, DocumentStatus[] | null> = {
|
||||
all: null,
|
||||
// "Needs review" surfaces both routed-for-review and flagged docs — the two
|
||||
// states that demand a human decision.
|
||||
"needs-review": ["needs-review", "flagged"],
|
||||
processed: ["processed"],
|
||||
archived: ["archived"],
|
||||
};
|
||||
|
||||
function countFor(docs: ReviewDocument[], filter: QueueFilter): number {
|
||||
const statuses = FILTER_STATUSES[filter];
|
||||
if (statuses === null) return docs.length;
|
||||
return docs.filter((d) => statuses.includes(d.status)).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* The review/approval queue: KPI strip, status filter pills, the document
|
||||
* stream table, and a detail drawer. The primary Documents surface.
|
||||
*/
|
||||
export function ReviewQueue() {
|
||||
const { tier } = useTier();
|
||||
const state = useAsync<DocumentsResponse>(() => fetchDocuments(tier), [tier]);
|
||||
const { data, loading } = state;
|
||||
const { isLoading, isEmpty } = useSectionFlags(state);
|
||||
|
||||
const [filter, setFilter] = useState<QueueFilter>("all");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const documents = useMemo(() => data?.documents ?? [], [data]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const statuses = FILTER_STATUSES[filter];
|
||||
if (statuses === null) return documents;
|
||||
return documents.filter((d) => statuses.includes(d.status));
|
||||
}, [documents, filter]);
|
||||
|
||||
const selected = documents.find((d) => d.id === selectedId) ?? null;
|
||||
|
||||
const filterItems: TabItem<QueueFilter>[] = [
|
||||
{ key: "all", label: "All", count: countFor(documents, "all") },
|
||||
{
|
||||
key: "needs-review",
|
||||
label: "Needs review",
|
||||
count: countFor(documents, "needs-review"),
|
||||
},
|
||||
{
|
||||
key: "processed",
|
||||
label: "Processed",
|
||||
count: countFor(documents, "processed"),
|
||||
},
|
||||
{
|
||||
key: "archived",
|
||||
label: "Archived",
|
||||
count: countFor(documents, "archived"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="portal-documents__queue">
|
||||
<DocumentsSummaryStrip
|
||||
summary={data?.summary ?? null}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<Tabs<QueueFilter>
|
||||
items={filterItems}
|
||||
activeKey={filter}
|
||||
onChange={setFilter}
|
||||
variant="pill"
|
||||
ariaLabel="Filter documents by status"
|
||||
/>
|
||||
|
||||
{isLoading && (
|
||||
<div className="portal-documents__table-skeleton" aria-hidden>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} height="3rem" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
title="No documents in the queue"
|
||||
description="As sources feed documents into your pipelines they'll appear here for review."
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && !isEmpty && (
|
||||
<ReviewQueueTable
|
||||
documents={rows}
|
||||
onRowClick={(d) => setSelectedId(d.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DocumentDrawer doc={selected} onClose={() => setSelectedId(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { documentsFor } from "@portal/mocks/documents";
|
||||
import { ReviewQueueTable } from "@portal/components/documents/ReviewQueueTable";
|
||||
import "@portal/views/Documents.css";
|
||||
|
||||
const DOCS = documentsFor("enterprise");
|
||||
|
||||
const meta: Meta<typeof ReviewQueueTable> = {
|
||||
title: "Portal/Documents/ReviewQueueTable",
|
||||
component: ReviewQueueTable,
|
||||
parameters: { layout: "padded" },
|
||||
args: { documents: DOCS, onRowClick: () => {} },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "78rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ReviewQueueTable>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Only the rows demanding a decision. */
|
||||
export const NeedsReview: Story = {
|
||||
args: {
|
||||
documents: DOCS.filter(
|
||||
(d) => d.status === "needs-review" || d.status === "flagged",
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: { documents: [] },
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
ProgressBar,
|
||||
StatusBadge,
|
||||
Table,
|
||||
type TableColumn,
|
||||
} from "@shared/components";
|
||||
import {
|
||||
DOCUMENT_STATUS_LABEL,
|
||||
DOCUMENT_STATUS_TONE,
|
||||
type ReviewDocument,
|
||||
} from "@portal/api/documents";
|
||||
import {
|
||||
confidencePct,
|
||||
confidenceTone,
|
||||
} from "@portal/components/documents/format";
|
||||
|
||||
interface ReviewQueueTableProps {
|
||||
documents: ReviewDocument[];
|
||||
onRowClick: (doc: ReviewDocument) => void;
|
||||
}
|
||||
|
||||
/** The document stream — one row per document awaiting a review decision. */
|
||||
export function ReviewQueueTable({
|
||||
documents,
|
||||
onRowClick,
|
||||
}: ReviewQueueTableProps) {
|
||||
const columns = useMemo<TableColumn<ReviewDocument>[]>(
|
||||
() => [
|
||||
{
|
||||
key: "name",
|
||||
header: "Name",
|
||||
render: (d) => (
|
||||
<div className="portal-documents__name-cell">
|
||||
<span className="portal-documents__name">{d.name}</span>
|
||||
{d.sensitive && (
|
||||
<span
|
||||
className="portal-documents__lock"
|
||||
title="Sensitive — access required"
|
||||
aria-label="Sensitive"
|
||||
>
|
||||
🔒
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "type", header: "Type", render: (d) => d.type },
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
render: (d) => (
|
||||
<StatusBadge tone={DOCUMENT_STATUS_TONE[d.status]} size="sm">
|
||||
{DOCUMENT_STATUS_LABEL[d.status]}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "source",
|
||||
header: "Source",
|
||||
render: (d) => (
|
||||
<span className="portal-documents__muted">{d.source}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "confidence",
|
||||
header: "Confidence",
|
||||
width: "9rem",
|
||||
render: (d) => (
|
||||
<div className="portal-documents__confidence">
|
||||
<ProgressBar value={d.confidence} height={6} />
|
||||
<span
|
||||
className={`portal-documents__confidence-pct portal-documents__confidence-pct--${confidenceTone(
|
||||
d.confidence,
|
||||
)}`}
|
||||
>
|
||||
{confidencePct(d.confidence)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "fields",
|
||||
header: "Fields",
|
||||
align: "right",
|
||||
render: (d) => (
|
||||
<span className="portal-documents__mono">{d.fieldsExtracted}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "time",
|
||||
header: "Time",
|
||||
align: "right",
|
||||
render: (d) => (
|
||||
<span className="portal-documents__muted">{d.time}</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Table<ReviewDocument>
|
||||
className="portal-documents__table"
|
||||
columns={columns}
|
||||
rows={documents}
|
||||
rowKey={(d) => d.id}
|
||||
onRowClick={onRowClick}
|
||||
empty="No documents match this filter."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { StatusTone } from "@shared/components";
|
||||
|
||||
/** Format a 0–1 confidence fraction as a whole-percent string. */
|
||||
export function confidencePct(n: number): string {
|
||||
return `${Math.round(n * 100)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tone for a confidence value. The bands match the review thresholds: below
|
||||
* 60% is unreliable (danger), 60–85% warrants a glance (warning), above is
|
||||
* trusted (success).
|
||||
*/
|
||||
export function confidenceTone(n: number): StatusTone {
|
||||
if (n < 0.6) return "danger";
|
||||
if (n < 0.85) return "warning";
|
||||
return "success";
|
||||
}
|
||||
|
||||
/** Window a freshly granted elevation stays valid, in seconds. */
|
||||
export const ELEVATION_WINDOW_SECONDS = 15 * 60;
|
||||
|
||||
/** Format remaining elevation seconds as M:SS for the countdown banner. */
|
||||
export function formatCountdown(totalSeconds: number): string {
|
||||
const safe = Math.max(0, totalSeconds);
|
||||
const minutes = Math.floor(safe / 60);
|
||||
const seconds = safe % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { CredentialRotationCard } from "@portal/components/editor-admin/CredentialRotationCard";
|
||||
import "@portal/views/EditorAdmin.css";
|
||||
|
||||
const meta: Meta<typeof CredentialRotationCard> = {
|
||||
title: "Portal/EditorAdmin/CredentialRotationCard",
|
||||
component: CredentialRotationCard,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "40rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CredentialRotationCard>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
serviceToken: {
|
||||
masked: "svc_live_••••••••••••7b21",
|
||||
lastRotated: "34 days ago",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Press "Rotate" in the story to see the post-rotation warning banner. */
|
||||
export const RecentlyRotated: Story = {
|
||||
args: {
|
||||
serviceToken: {
|
||||
masked: "svc_live_••••••••••••7b21",
|
||||
lastRotated: "2 days ago",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useState } from "react";
|
||||
import { Banner, Button, Card, StatTile } from "@shared/components";
|
||||
import type { DeploymentSummary } from "@portal/api/editorDeploy";
|
||||
|
||||
interface Props {
|
||||
serviceToken: DeploymentSummary["serviceToken"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotation control for the deployment's service token — the credential
|
||||
* instances use to authenticate back to the org. Rotation is a demo shell:
|
||||
* it flashes a warning that running instances must pick up the new value, but
|
||||
* there's no submit endpoint yet.
|
||||
*/
|
||||
export function CredentialRotationCard({ serviceToken }: Props) {
|
||||
const [rotating, setRotating] = useState(false);
|
||||
const [rotated, setRotated] = useState(false);
|
||||
|
||||
function rotate() {
|
||||
// TODO(backend): POST /v1/editor/deployment/rotate → mints a new service
|
||||
// token and revokes the old one after a grace window.
|
||||
setRotating(true);
|
||||
setTimeout(() => {
|
||||
setRotating(false);
|
||||
setRotated(true);
|
||||
}, 700);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding="default" className="portal-editor__panel">
|
||||
<div className="portal-editor__panel-head">
|
||||
<div>
|
||||
<h3 className="portal-editor__panel-title">Service token</h3>
|
||||
<p className="portal-editor__panel-sub">
|
||||
Instances authenticate to the org with this credential. Rotate it on
|
||||
a schedule or immediately after a suspected leak.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="portal-editor__token-row">
|
||||
<StatTile label="Current token" value={serviceToken.masked} />
|
||||
<StatTile label="Last rotated" value={serviceToken.lastRotated} />
|
||||
</div>
|
||||
|
||||
{rotated && (
|
||||
<Banner
|
||||
tone="warning"
|
||||
title="Rotate running instances"
|
||||
description="A new token was issued. Update each self-hosted instance's STIRLING_SERVICE_TOKEN within the 24h grace window or they'll drop offline."
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="portal-editor__panel-actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
accent="amber"
|
||||
loading={rotating}
|
||||
onClick={rotate}
|
||||
>
|
||||
Rotate service token
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { buildEditorDeploymentResponse } from "@portal/mocks/editorDeploy";
|
||||
import { DeploymentSummaryStrip } from "@portal/components/editor-admin/DeploymentSummaryStrip";
|
||||
import "@portal/views/EditorAdmin.css";
|
||||
|
||||
const meta: Meta<typeof DeploymentSummaryStrip> = {
|
||||
title: "Portal/EditorAdmin/DeploymentSummaryStrip",
|
||||
component: DeploymentSummaryStrip,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DeploymentSummaryStrip>;
|
||||
|
||||
export const Pro: Story = {
|
||||
args: { summary: buildEditorDeploymentResponse("pro").summary },
|
||||
};
|
||||
|
||||
export const Enterprise: Story = {
|
||||
args: { summary: buildEditorDeploymentResponse("enterprise").summary },
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: { loading: true },
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MetricCard, MetricStrip, Skeleton } from "@shared/components";
|
||||
import type { DeploymentSummary } from "@portal/api/editorDeploy";
|
||||
|
||||
interface Props {
|
||||
summary?: DeploymentSummary;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/** Top-of-page metric strip summarising the org's Editor deployment health. */
|
||||
export function DeploymentSummaryStrip({ summary, loading }: Props) {
|
||||
if (loading || !summary) {
|
||||
return (
|
||||
<MetricStrip>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} height="5.5rem" />
|
||||
))}
|
||||
</MetricStrip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MetricStrip>
|
||||
{summary.metrics.map((m) => (
|
||||
<MetricCard
|
||||
key={m.label}
|
||||
label={m.label}
|
||||
value={m.value}
|
||||
delta={m.delta}
|
||||
deltaDirection={m.deltaDirection}
|
||||
description={m.description}
|
||||
/>
|
||||
))}
|
||||
</MetricStrip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { buildEditorDeploymentResponse } from "@portal/mocks/editorDeploy";
|
||||
import { TierProvider } from "@portal/contexts/TierContext";
|
||||
import { DeploymentTargets } from "@portal/components/editor-admin/DeploymentTargets";
|
||||
import "@portal/views/EditorAdmin.css";
|
||||
|
||||
const meta: Meta<typeof DeploymentTargets> = {
|
||||
title: "Portal/EditorAdmin/DeploymentTargets",
|
||||
component: DeploymentTargets,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DeploymentTargets>;
|
||||
|
||||
/** Free: only Managed Cloud runs; Docker + K8s are locked behind a paywall. */
|
||||
export const Free: Story = {
|
||||
args: { targets: buildEditorDeploymentResponse("free").targets },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<TierProvider initialTier="free">
|
||||
<S />
|
||||
</TierProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
/** Pro: Docker running, Kubernetes available but not yet deployed. */
|
||||
export const Pro: Story = {
|
||||
args: { targets: buildEditorDeploymentResponse("pro").targets },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<TierProvider initialTier="pro">
|
||||
<S />
|
||||
</TierProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export const Enterprise: Story = {
|
||||
args: { targets: buildEditorDeploymentResponse("enterprise").targets },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<TierProvider initialTier="enterprise">
|
||||
<S />
|
||||
</TierProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Button, Card, CodeBlock, StatusBadge } from "@shared/components";
|
||||
import { TARGET_META, type DeploymentTarget } from "@portal/api/editorDeploy";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
|
||||
const STATE_BADGE: Record<
|
||||
DeploymentTarget["state"],
|
||||
{ label: string; tone: "success" | "info" | "neutral" }
|
||||
> = {
|
||||
running: { label: "Running", tone: "success" },
|
||||
available: { label: "Available", tone: "info" },
|
||||
locked: { label: "Locked", tone: "neutral" },
|
||||
};
|
||||
|
||||
/** Upgrade-nudge copy for a target gated behind a higher tier. */
|
||||
function lockCopy(target: DeploymentTarget): string {
|
||||
return target.requiresTier === "enterprise"
|
||||
? "On-prem and Kubernetes self-hosting are part of Enterprise."
|
||||
: "Self-hosting with Docker and Kubernetes unlocks on a paid plan.";
|
||||
}
|
||||
|
||||
interface Props {
|
||||
targets: DeploymentTarget[];
|
||||
/** Invoked from a locked target's upgrade nudge. */
|
||||
onUpgrade?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The three deployment targets (Managed Cloud / Docker / Kubernetes). Unlocked
|
||||
* targets show their install/run snippet; locked ones swap it for an upgrade
|
||||
* nudge so the value of the higher tier is visible inline.
|
||||
*/
|
||||
export function DeploymentTargets({ targets, onUpgrade }: Props) {
|
||||
const { tier } = useTier();
|
||||
|
||||
return (
|
||||
<div className="portal-editor__targets">
|
||||
{targets.map((t) => {
|
||||
const meta = TARGET_META[t.kind];
|
||||
const badge = STATE_BADGE[t.state];
|
||||
const locked = t.state === "locked";
|
||||
return (
|
||||
<Card
|
||||
key={t.kind}
|
||||
padding="default"
|
||||
className="portal-editor__target"
|
||||
>
|
||||
<div className="portal-editor__target-head">
|
||||
<span
|
||||
className={`portal-editor__target-icon portal-editor__target-icon--${meta.tone}`}
|
||||
aria-hidden
|
||||
>
|
||||
{meta.icon}
|
||||
</span>
|
||||
<div className="portal-editor__target-titles">
|
||||
<h3 className="portal-editor__target-name">{t.label}</h3>
|
||||
<p className="portal-editor__target-tagline">{t.tagline}</p>
|
||||
</div>
|
||||
<StatusBadge
|
||||
tone={badge.tone}
|
||||
size="sm"
|
||||
pulse={t.state === "running"}
|
||||
>
|
||||
{badge.label}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
|
||||
{t.state === "running" && (
|
||||
<p className="portal-editor__target-meta">
|
||||
v{t.runningVersion} · {t.instanceCount}{" "}
|
||||
{t.instanceCount === 1 ? "instance" : "instances"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{locked ? (
|
||||
<div className="portal-editor__lock">
|
||||
<p className="portal-editor__lock-copy">{lockCopy(t)}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
accent={t.requiresTier === "enterprise" ? "purple" : "blue"}
|
||||
onClick={onUpgrade}
|
||||
disabled={tier === "enterprise"}
|
||||
>
|
||||
{t.requiresTier === "enterprise"
|
||||
? "Talk to sales"
|
||||
: "Upgrade plan"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<CodeBlock
|
||||
code={t.snippet}
|
||||
lang={t.snippetLang}
|
||||
caption={t.label}
|
||||
maxHeight={180}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { buildEditorDeploymentResponse } from "@portal/mocks/editorDeploy";
|
||||
import { InstanceHealthTable } from "@portal/components/editor-admin/InstanceHealthTable";
|
||||
import "@portal/views/EditorAdmin.css";
|
||||
|
||||
const meta: Meta<typeof InstanceHealthTable> = {
|
||||
title: "Portal/EditorAdmin/InstanceHealthTable",
|
||||
component: InstanceHealthTable,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof InstanceHealthTable>;
|
||||
|
||||
/** Pro: Managed Cloud + two Docker edge nodes, one on a stale version. */
|
||||
export const Pro: Story = {
|
||||
args: { instances: buildEditorDeploymentResponse("pro").instances },
|
||||
};
|
||||
|
||||
/** Enterprise: adds the K8s fleet and an offline air-gapped node. */
|
||||
export const Enterprise: Story = {
|
||||
args: { instances: buildEditorDeploymentResponse("enterprise").instances },
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: { instances: [] },
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
Card,
|
||||
Chip,
|
||||
EmptyState,
|
||||
StatusBadge,
|
||||
Table,
|
||||
type TableColumn,
|
||||
} from "@shared/components";
|
||||
import {
|
||||
INSTANCE_STATUS_LABEL,
|
||||
INSTANCE_STATUS_TONE,
|
||||
TARGET_META,
|
||||
type EditorInstance,
|
||||
} from "@portal/api/editorDeploy";
|
||||
|
||||
const TARGET_LABEL: Record<EditorInstance["target"], string> = {
|
||||
cloud: "Cloud",
|
||||
docker: "Docker",
|
||||
kubernetes: "K8s",
|
||||
};
|
||||
|
||||
const cols: TableColumn<EditorInstance>[] = [
|
||||
{
|
||||
key: "host",
|
||||
header: "Host",
|
||||
render: (i) => (
|
||||
<div className="portal-editor__cell-stack">
|
||||
<span className="portal-editor__cell-strong">{i.host}</span>
|
||||
<span className="portal-editor__cell-muted">
|
||||
<Chip size="sm" tone={TARGET_META[i.target].tone}>
|
||||
{TARGET_LABEL[i.target]}
|
||||
</Chip>
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "version",
|
||||
header: "Version",
|
||||
render: (i) => <code className="portal-editor__mono">{i.version}</code>,
|
||||
},
|
||||
{
|
||||
key: "region",
|
||||
header: "Region",
|
||||
render: (i) => <span className="portal-editor__mono">{i.region}</span>,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
render: (i) => (
|
||||
<StatusBadge
|
||||
tone={INSTANCE_STATUS_TONE[i.status]}
|
||||
size="sm"
|
||||
pulse={i.status === "healthy"}
|
||||
>
|
||||
{INSTANCE_STATUS_LABEL[i.status]}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "lastSeen",
|
||||
header: "Last seen",
|
||||
render: (i) => <span className="portal-editor__muted">{i.lastSeen}</span>,
|
||||
},
|
||||
{
|
||||
key: "activeUsers",
|
||||
header: "Active users",
|
||||
align: "right",
|
||||
render: (i) => <span className="portal-editor__mono">{i.activeUsers}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
instances: EditorInstance[];
|
||||
}
|
||||
|
||||
/** Live health for every Editor instance reporting in to the org. */
|
||||
export function InstanceHealthTable({ instances }: Props) {
|
||||
return (
|
||||
<Card padding="none">
|
||||
{instances.length === 0 ? (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title="No instances reporting"
|
||||
description="Deploy a target and pair it to see live instance health here."
|
||||
/>
|
||||
) : (
|
||||
<Table columns={cols} rows={instances} rowKey={(i) => i.id} />
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { OfflineActivationCard } from "@portal/components/editor-admin/OfflineActivationCard";
|
||||
import "@portal/views/EditorAdmin.css";
|
||||
|
||||
const meta: Meta<typeof OfflineActivationCard> = {
|
||||
title: "Portal/EditorAdmin/OfflineActivationCard",
|
||||
component: OfflineActivationCard,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "40rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof OfflineActivationCard>;
|
||||
|
||||
/** Enterprise: bundle generation is live. */
|
||||
export const Available: Story = {
|
||||
args: { available: true },
|
||||
};
|
||||
|
||||
/** Free / Pro: locked behind an upgrade nudge. */
|
||||
export const Locked: Story = {
|
||||
args: { available: false },
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState } from "react";
|
||||
import { Banner, Button, Card } from "@shared/components";
|
||||
|
||||
interface Props {
|
||||
/** Enterprise-only. When false the card renders an upgrade nudge. */
|
||||
available: boolean;
|
||||
onUpgrade?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Air-gapped / offline activation. Networks with no outbound path can't pair
|
||||
* live, so this generates a signed activation bundle to carry in by hand.
|
||||
* Enterprise-only; lower tiers see an upgrade nudge. Generation is a demo
|
||||
* shell with no submit endpoint yet.
|
||||
*/
|
||||
export function OfflineActivationCard({ available, onUpgrade }: Props) {
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [generated, setGenerated] = useState(false);
|
||||
|
||||
function generate() {
|
||||
// TODO(backend): POST /v1/editor/deployment/offline-bundle → streams a
|
||||
// signed .stirlingpkg activation bundle for an air-gapped install.
|
||||
setGenerating(true);
|
||||
setTimeout(() => {
|
||||
setGenerating(false);
|
||||
setGenerated(true);
|
||||
}, 900);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding="default" accent="purple" className="portal-editor__panel">
|
||||
<div className="portal-editor__panel-head">
|
||||
<div>
|
||||
<h3 className="portal-editor__panel-title">
|
||||
Air-gapped activation
|
||||
<span className="portal-editor__enterprise-tag">Enterprise</span>
|
||||
</h3>
|
||||
<p className="portal-editor__panel-sub">
|
||||
Generate a signed activation bundle for an offline or on-prem
|
||||
install with no outbound network path. Transfer it to the instance
|
||||
and apply it during first-run setup.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!available ? (
|
||||
<div className="portal-editor__lock">
|
||||
<p className="portal-editor__lock-copy">
|
||||
Offline and on-prem activation is part of Enterprise.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
accent="purple"
|
||||
size="sm"
|
||||
onClick={onUpgrade}
|
||||
>
|
||||
Talk to sales
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{generated && (
|
||||
<Banner
|
||||
tone="success"
|
||||
title="Bundle ready"
|
||||
description="activation-acme-3.2.1.stirlingpkg is signed and ready to transfer. It activates one instance and expires in 14 days."
|
||||
/>
|
||||
)}
|
||||
<div className="portal-editor__panel-actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
accent="purple"
|
||||
loading={generating}
|
||||
onClick={generate}
|
||||
>
|
||||
Generate offline bundle
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { buildEditorDeploymentResponse } from "@portal/mocks/editorDeploy";
|
||||
import { PairingPanel } from "@portal/components/editor-admin/PairingPanel";
|
||||
import "@portal/views/EditorAdmin.css";
|
||||
|
||||
const meta: Meta<typeof PairingPanel> = {
|
||||
title: "Portal/EditorAdmin/PairingPanel",
|
||||
component: PairingPanel,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PairingPanel>;
|
||||
|
||||
/** Pro: token + short code usable, IaC locked behind Enterprise. */
|
||||
export const Pro: Story = {
|
||||
args: { pairings: buildEditorDeploymentResponse("pro").pairings },
|
||||
};
|
||||
|
||||
/** Enterprise: all three methods unlocked, including the IaC module snippet. */
|
||||
export const Enterprise: Story = {
|
||||
args: { pairings: buildEditorDeploymentResponse("enterprise").pairings },
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Card, Chip, CodeBlock } from "@shared/components";
|
||||
import type { PairingMethod, PairingOption } from "@portal/api/editorDeploy";
|
||||
|
||||
const METHOD_ICON: Record<PairingMethod, string> = {
|
||||
token: "🔑",
|
||||
shortcode: "📺",
|
||||
iac: "🧱",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
pairings: PairingOption[];
|
||||
onUpgrade?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pairing options for connecting a self-hosted editor to the org — a long-lived
|
||||
* pairing token, a TV-style short code, and IaC provisioning. The
|
||||
* generate/rotate affordance is a demo shell: it shows transient feedback but
|
||||
* has no submit endpoint yet.
|
||||
*/
|
||||
export function PairingPanel({ pairings, onUpgrade }: Props) {
|
||||
// Tracks which option just got a (mock) rotate so we can flash confirmation.
|
||||
const [rotated, setRotated] = useState<PairingMethod | null>(null);
|
||||
|
||||
function rotate(method: PairingMethod) {
|
||||
// TODO(backend): POST /v1/editor/pairings { method } → returns a freshly
|
||||
// minted token / short code / IaC reference.
|
||||
setRotated(method);
|
||||
setTimeout(() => setRotated((m) => (m === method ? null : m)), 1800);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="portal-editor__pairings">
|
||||
{pairings.map((p) => {
|
||||
const isCode = p.method === "iac";
|
||||
return (
|
||||
<Card
|
||||
key={p.method}
|
||||
padding="default"
|
||||
className="portal-editor__pairing"
|
||||
>
|
||||
<div className="portal-editor__pairing-head">
|
||||
<span className="portal-editor__pairing-icon" aria-hidden>
|
||||
{METHOD_ICON[p.method]}
|
||||
</span>
|
||||
<div className="portal-editor__pairing-titles">
|
||||
<h3 className="portal-editor__pairing-name">{p.label}</h3>
|
||||
<p className="portal-editor__pairing-desc">{p.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{p.locked ? (
|
||||
<div className="portal-editor__lock">
|
||||
<p className="portal-editor__lock-copy">
|
||||
IaC provisioning is part of Enterprise.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
accent="purple"
|
||||
onClick={onUpgrade}
|
||||
>
|
||||
Talk to sales
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{isCode ? (
|
||||
<CodeBlock code={p.value} lang="plain" maxHeight={80} />
|
||||
) : (
|
||||
<div className="portal-editor__pairing-value">
|
||||
<code>{p.value}</code>
|
||||
</div>
|
||||
)}
|
||||
<div className="portal-editor__pairing-foot">
|
||||
{p.expires && (
|
||||
<Chip size="sm" tone="neutral">
|
||||
{p.expires}
|
||||
</Chip>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => rotate(p.method)}
|
||||
>
|
||||
{rotated === p.method
|
||||
? "Generated ✓"
|
||||
: p.method === "shortcode"
|
||||
? "Generate new code"
|
||||
: "Rotate"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -200,3 +200,46 @@ export function SendIcon(props: IconProps) {
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UsersIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PoliciesIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComponentsIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentBuilderIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<rect x="3" y="11" width="18" height="10" rx="2" />
|
||||
<circle cx="12" cy="5" r="2" />
|
||||
<path d="M12 7v4" />
|
||||
<line x1="8" y1="16" x2="8" y2="16" />
|
||||
<line x1="16" y1="16" x2="16" y2="16" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ModelsTab } from "@portal/components/infrastructure/ModelsTab";
|
||||
import "@portal/views/Infrastructure.css";
|
||||
|
||||
// Data is served by the registered MSW infrastructure handler; the tier global
|
||||
// (toolbar) drives which catalogue + routing slice each story renders.
|
||||
const meta = {
|
||||
title: "Infrastructure/ModelsTab",
|
||||
component: ModelsTab,
|
||||
parameters: { layout: "padded" },
|
||||
} satisfies Meta<typeof ModelsTab>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Pro: full managed catalogue plus routing control. */
|
||||
export const Pro: Story = {
|
||||
globals: { tier: "pro" },
|
||||
};
|
||||
|
||||
/** Free: two managed models, no routing (upgrade nudge). */
|
||||
export const Free: Story = {
|
||||
globals: { tier: "free" },
|
||||
};
|
||||
|
||||
/** Enterprise: adds bring-your-own / on-prem models and per-region pinning. */
|
||||
export const Enterprise: Story = {
|
||||
globals: { tier: "enterprise" },
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
import {
|
||||
Banner,
|
||||
Card,
|
||||
Chip,
|
||||
EmptyState,
|
||||
MetricCard,
|
||||
ProgressBar,
|
||||
Select,
|
||||
StatusBadge,
|
||||
Table,
|
||||
type SelectOption,
|
||||
type TableColumn,
|
||||
} from "@shared/components";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
import {
|
||||
fetchModels,
|
||||
type ModelEntry,
|
||||
type ModelsResponse,
|
||||
type RoutingRule,
|
||||
} from "@portal/api/infrastructure";
|
||||
import { SectionHeader } from "@portal/components/infrastructure/SectionHeader";
|
||||
import { TableSkeleton } from "@portal/components/infrastructure/TableSkeleton";
|
||||
import {
|
||||
MODEL_LABEL,
|
||||
MODEL_PROVIDER_LABEL,
|
||||
MODEL_TONE,
|
||||
MODEL_TYPE_LABEL,
|
||||
MODEL_TYPE_TONE,
|
||||
modelCost,
|
||||
pct,
|
||||
} from "@portal/components/infrastructure/infraFormat";
|
||||
|
||||
const modelCols: TableColumn<ModelEntry>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "Model",
|
||||
render: (m) => (
|
||||
<div className="portal-infra__cell-stack">
|
||||
<span className="portal-infra__cell-strong">{m.name}</span>
|
||||
<Chip tone="neutral" size="sm">
|
||||
{MODEL_PROVIDER_LABEL[m.provider]}
|
||||
</Chip>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
header: "Type",
|
||||
render: (m) => (
|
||||
<Chip tone={MODEL_TYPE_TONE[m.type]} size="sm">
|
||||
{MODEL_TYPE_LABEL[m.type]}
|
||||
</Chip>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
render: (m) => (
|
||||
<StatusBadge
|
||||
tone={MODEL_TONE[m.status]}
|
||||
size="sm"
|
||||
pulse={m.status === "active"}
|
||||
>
|
||||
{MODEL_LABEL[m.status]}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "load",
|
||||
header: "Load",
|
||||
width: "9rem",
|
||||
render: (m) => (
|
||||
<div className="portal-infra__load">
|
||||
<ProgressBar value={m.load} thresholded height={6} />
|
||||
<span className="portal-infra__load-pct">{pct(m.load)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "latency",
|
||||
header: "Latency",
|
||||
align: "right",
|
||||
render: (m) => <span className="portal-infra__mono">{m.latencyMs} ms</span>,
|
||||
},
|
||||
{
|
||||
key: "cost",
|
||||
header: "Cost",
|
||||
align: "right",
|
||||
render: (m) => (
|
||||
<span className="portal-infra__mono">
|
||||
{modelCost(m.cost, m.costUnit)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "version",
|
||||
header: "Version",
|
||||
render: (m) => <code className="portal-infra__cell-code">{m.version}</code>,
|
||||
},
|
||||
];
|
||||
|
||||
export function ModelsTab() {
|
||||
const { tier } = useTier();
|
||||
const state = useAsync<ModelsResponse>(() => fetchModels(tier), [tier]);
|
||||
const { data } = state;
|
||||
const { isLoading, isEmpty } = useSectionFlags(state);
|
||||
|
||||
// Free has no routing control: the catalogue is read-only and the routing
|
||||
// table is replaced by an upgrade nudge.
|
||||
const canRoute = tier !== "free";
|
||||
|
||||
// Routing overrides are interactive but unbacked — assigning a model just
|
||||
// moves local UI state until the routing endpoint exists.
|
||||
// TODO(backend): PUT /v1/infrastructure/models/routing { rules }
|
||||
const modelOptions: SelectOption[] =
|
||||
data?.models
|
||||
.filter((m) => m.status !== "disabled")
|
||||
.map((m) => ({ value: m.id, label: m.name })) ?? [];
|
||||
|
||||
const routingCols: TableColumn<RoutingRule>[] = [
|
||||
{
|
||||
key: "operation",
|
||||
header: "Operation",
|
||||
render: (r) => (
|
||||
<div className="portal-infra__cell-stack">
|
||||
<span className="portal-infra__cell-strong">{r.operation}</span>
|
||||
{r.isDefault && (
|
||||
<Chip tone="blue" size="sm">
|
||||
Default
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "docType", header: "Document type", render: (r) => r.docType },
|
||||
{
|
||||
key: "modelId",
|
||||
header: "Routed to",
|
||||
width: "16rem",
|
||||
render: (r) => (
|
||||
<Select
|
||||
inputSize="sm"
|
||||
options={modelOptions}
|
||||
defaultValue={r.modelId}
|
||||
aria-label={`Model for ${r.operation}`}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="portal-infra__stack">
|
||||
<SectionHeader
|
||||
title="Models"
|
||||
sub="The model catalogue and routing that powers document processing across your workspace."
|
||||
/>
|
||||
|
||||
{data && (
|
||||
<section className="portal-infra__metrics">
|
||||
<MetricCard label="Active models" value={data.summary.activeModels} />
|
||||
<MetricCard
|
||||
label="Avg latency"
|
||||
value={`${data.summary.avgLatencyMs} ms`}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Monthly model spend"
|
||||
value={
|
||||
data.summary.monthlySpend > 0
|
||||
? `$${data.summary.monthlySpend.toLocaleString()}`
|
||||
: "Included"
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Catalogue"
|
||||
sub={
|
||||
tier === "enterprise"
|
||||
? "Managed, bring-your-own, and on-prem models — with per-region pinning available."
|
||||
: "Managed models available to your workspace, with live latency and cost."
|
||||
}
|
||||
/>
|
||||
<Card padding="none">
|
||||
{isLoading && <TableSkeleton rows={4} cols={7} />}
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title="No models available"
|
||||
description="Models in your workspace's catalogue appear here."
|
||||
/>
|
||||
)}
|
||||
{!isEmpty && data && data.models.length > 0 && (
|
||||
<Table
|
||||
columns={modelCols}
|
||||
rows={data.models}
|
||||
rowKey={(m) => m.id}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{tier === "enterprise" && (
|
||||
<Banner
|
||||
tone="info"
|
||||
title="Bring your own model"
|
||||
description="Register an on-prem or self-hosted model and pin it to a region for data-residency-bound processing."
|
||||
/>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Routing rules"
|
||||
sub={
|
||||
canRoute
|
||||
? "Which model handles each operation. The default applies when no narrower rule matches."
|
||||
: "Route operations to specific models — available on paid plans."
|
||||
}
|
||||
/>
|
||||
{canRoute ? (
|
||||
<Card padding="none">
|
||||
{isLoading && <TableSkeleton rows={4} cols={3} />}
|
||||
{!isEmpty && data && (
|
||||
<Table
|
||||
columns={routingCols}
|
||||
rows={data.routing}
|
||||
rowKey={(r) => r.id}
|
||||
empty="No routing rules configured."
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
) : (
|
||||
<Banner
|
||||
tone="info"
|
||||
title="Model routing is a paid feature"
|
||||
description="Upgrade to Pro to control which model handles each operation and document type."
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,18 @@ type Story = StoryObj<typeof SecurityTab>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
// Enterprise unlocks HYOK key custody (with a live rotate affordance) and the
|
||||
// full attested compliance set, including PCI in-scope.
|
||||
export const Enterprise: Story = {
|
||||
globals: { tier: "enterprise" },
|
||||
};
|
||||
|
||||
// Free runs on Stirling-managed keys (rotate disabled, upgrade nudge) and a
|
||||
// trimmed attestation set with HIPAA/PCI not-applicable.
|
||||
export const Free: Story = {
|
||||
globals: { tier: "free" },
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Card,
|
||||
Chip,
|
||||
EmptyState,
|
||||
RadioGroup,
|
||||
Skeleton,
|
||||
@@ -20,8 +22,12 @@ import {
|
||||
} from "@portal/api/infrastructure";
|
||||
import { SectionHeader } from "@portal/components/infrastructure/SectionHeader";
|
||||
import {
|
||||
ATTESTATION_LABEL,
|
||||
ATTESTATION_TONE,
|
||||
CERT_LABEL,
|
||||
CERT_TONE,
|
||||
KEY_MODE_LABEL,
|
||||
KEY_MODE_TONE,
|
||||
} from "@portal/components/infrastructure/infraFormat";
|
||||
|
||||
const ACCESS_OPTS: RadioOption<AccessPolicy>[] = [
|
||||
@@ -146,6 +152,74 @@ export function SecurityTab() {
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Encryption key management"
|
||||
sub="Custody of the keys that encrypt documents at rest — who can decrypt, and how keys rotate."
|
||||
/>
|
||||
<Card padding="loose" className="portal-infra__keymgmt">
|
||||
<div className="portal-infra__keymgmt-head">
|
||||
<div className="portal-infra__keymgmt-title">
|
||||
<span className="portal-infra__cell-strong">
|
||||
{data.keyManagement.provider}
|
||||
</span>
|
||||
<StatusBadge
|
||||
tone={KEY_MODE_TONE[data.keyManagement.mode]}
|
||||
size="sm"
|
||||
>
|
||||
{KEY_MODE_LABEL[data.keyManagement.mode]}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
{/* Rotation is a privileged backend action; disabled where Stirling
|
||||
holds the keys (managed tiers can't rotate customer keys). */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!data.keyManagement.customerManaged}
|
||||
onClick={() => {
|
||||
// TODO(backend): POST /v1/infrastructure/security/keys/rotate
|
||||
}}
|
||||
>
|
||||
Rotate key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<dl className="portal-infra__kv">
|
||||
<div className="portal-infra__kv-wide">
|
||||
<dt>Key identifier</dt>
|
||||
<dd>
|
||||
<code className="portal-infra__cell-code">
|
||||
{data.keyManagement.keyId}
|
||||
</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Algorithm</dt>
|
||||
<dd className="portal-infra__mono">
|
||||
{data.keyManagement.algorithm}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last rotated</dt>
|
||||
<dd>{data.keyManagement.lastRotated}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Rotation policy</dt>
|
||||
<dd>{data.keyManagement.rotationPolicy}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{!data.keyManagement.customerManaged && (
|
||||
<Banner
|
||||
tone="info"
|
||||
className="portal-infra__banner"
|
||||
title="Keys are managed by Stirling on your plan"
|
||||
description="Bring-your-own-key (BYOK) and hold-your-own-key (HYOK) custody are available on Enterprise. Upgrade to supply keys from your own KMS."
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Compliance"
|
||||
@@ -166,6 +240,45 @@ export function SecurityTab() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Compliance attestations"
|
||||
sub="Framework-by-framework audit posture, with reports available on attested controls."
|
||||
/>
|
||||
<div className="portal-infra__attestations">
|
||||
{data.attestations.map((a) => (
|
||||
<Card
|
||||
key={a.id}
|
||||
padding="default"
|
||||
className="portal-infra__attestation"
|
||||
>
|
||||
<div className="portal-infra__cert-head">
|
||||
<span className="portal-infra__cell-strong">{a.name}</span>
|
||||
<StatusBadge tone={ATTESTATION_TONE[a.status]} size="sm">
|
||||
{ATTESTATION_LABEL[a.status]}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<Chip tone="neutral" size="sm">
|
||||
{a.framework}
|
||||
</Chip>
|
||||
<p className="portal-infra__cert-detail">{a.detail}</p>
|
||||
{a.reportUrl ? (
|
||||
<a
|
||||
className="portal-infra__attestation-link"
|
||||
href={a.reportUrl}
|
||||
// TODO(backend): GET /v1/infrastructure/security/reports/:id
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
View report →
|
||||
</a>
|
||||
) : (
|
||||
<span className="portal-infra__muted">No report available</span>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="IP allowlist"
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import type { StatusTone } from "@shared/components";
|
||||
import type { ChipTone, StatusTone } from "@shared/components";
|
||||
import type {
|
||||
ApiKeyStatus,
|
||||
AttestationStatus,
|
||||
AuditCategory,
|
||||
AuditStatus,
|
||||
CertStatus,
|
||||
DeploymentStatus,
|
||||
KeyMode,
|
||||
ModelCostUnit,
|
||||
ModelProvider,
|
||||
ModelStatus,
|
||||
ModelType,
|
||||
RegionStatus,
|
||||
} from "@portal/api/infrastructure";
|
||||
|
||||
@@ -62,6 +68,31 @@ export const CERT_LABEL: Record<CertStatus, string> = {
|
||||
"not-started": "Not started",
|
||||
};
|
||||
|
||||
export const KEY_MODE_LABEL: Record<KeyMode, string> = {
|
||||
managed: "Stirling-managed",
|
||||
byok: "BYOK",
|
||||
hyok: "HYOK",
|
||||
};
|
||||
|
||||
export const KEY_MODE_TONE: Record<KeyMode, StatusTone> = {
|
||||
managed: "info",
|
||||
byok: "purple",
|
||||
// HYOK is the strongest posture (Stirling never sees plaintext) → success.
|
||||
hyok: "success",
|
||||
};
|
||||
|
||||
export const ATTESTATION_LABEL: Record<AttestationStatus, string> = {
|
||||
attested: "Attested",
|
||||
"in-scope": "In scope",
|
||||
"not-applicable": "N/A",
|
||||
};
|
||||
|
||||
export const ATTESTATION_TONE: Record<AttestationStatus, StatusTone> = {
|
||||
attested: "success",
|
||||
"in-scope": "warning",
|
||||
"not-applicable": "neutral",
|
||||
};
|
||||
|
||||
export const AUDIT_TONE: Record<AuditStatus, StatusTone> = {
|
||||
success: "success",
|
||||
warning: "warning",
|
||||
@@ -84,3 +115,43 @@ export const AUDIT_CAT_TONE: Record<AuditCategory, StatusTone> = {
|
||||
processing: "success",
|
||||
security: "warning",
|
||||
};
|
||||
|
||||
export const MODEL_TONE: Record<ModelStatus, StatusTone> = {
|
||||
active: "success",
|
||||
degraded: "warning",
|
||||
disabled: "neutral",
|
||||
};
|
||||
|
||||
export const MODEL_LABEL: Record<ModelStatus, string> = {
|
||||
active: "Active",
|
||||
degraded: "Degraded",
|
||||
disabled: "Disabled",
|
||||
};
|
||||
|
||||
export const MODEL_TYPE_LABEL: Record<ModelType, string> = {
|
||||
extraction: "Extraction",
|
||||
classification: "Classification",
|
||||
ocr: "OCR",
|
||||
llm: "LLM",
|
||||
};
|
||||
|
||||
export const MODEL_TYPE_TONE: Record<ModelType, ChipTone> = {
|
||||
extraction: "blue",
|
||||
classification: "purple",
|
||||
ocr: "green",
|
||||
llm: "amber",
|
||||
};
|
||||
|
||||
export const MODEL_PROVIDER_LABEL: Record<ModelProvider, string> = {
|
||||
stirling: "Stirling",
|
||||
openai: "OpenAI",
|
||||
anthropic: "Anthropic",
|
||||
"on-prem": "On-prem",
|
||||
};
|
||||
|
||||
/** Render a model's cost with the unit it's billed against. */
|
||||
export function modelCost(cost: number, unit: ModelCostUnit): string {
|
||||
if (cost === 0) return "Included";
|
||||
const price = `$${cost.toFixed(unit === "per-call" ? 3 : 2)}`;
|
||||
return unit === "per-call" ? `${price}/call` : `${price}/1k`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DeployedPipelinesTable } from "@portal/components/pipelines/DeployedPipelinesTable";
|
||||
import {
|
||||
DEGRADED_PIPELINE,
|
||||
HEALTHY_PIPELINE,
|
||||
} from "@portal/components/pipelines/storyFixtures";
|
||||
import "@portal/views/Pipelines.css";
|
||||
|
||||
const meta: Meta<typeof DeployedPipelinesTable> = {
|
||||
title: "Portal/Pipelines/DeployedPipelinesTable",
|
||||
component: DeployedPipelinesTable,
|
||||
parameters: { layout: "padded" },
|
||||
args: { onRowClick: () => {} },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "72rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DeployedPipelinesTable>;
|
||||
|
||||
/** A healthy pipeline at bound and one degraded below its golden-set bound. */
|
||||
export const Default: Story = {
|
||||
args: { pipelines: [HEALTHY_PIPELINE, DEGRADED_PIPELINE] },
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: { pipelines: [] },
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useMemo } from "react";
|
||||
import { StatusBadge, Table, type TableColumn } from "@shared/components";
|
||||
import type { Pipeline } from "@portal/api/pipelines";
|
||||
import { compact, goldenTone, pct } from "@portal/components/pipelines/format";
|
||||
|
||||
interface DeployedPipelinesTableProps {
|
||||
pipelines: Pipeline[];
|
||||
onRowClick: (p: Pipeline) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dense roster of the deployed fleet that puts golden-set reliability up front.
|
||||
* The card list below it carries the full per-pipeline story; this table is the
|
||||
* scannable "is anything below its bound?" view across the whole fleet.
|
||||
*/
|
||||
export function DeployedPipelinesTable({
|
||||
pipelines,
|
||||
onRowClick,
|
||||
}: DeployedPipelinesTableProps) {
|
||||
const columns = useMemo<TableColumn<Pipeline>[]>(
|
||||
() => [
|
||||
{
|
||||
key: "name",
|
||||
header: "Pipeline",
|
||||
render: (p) => (
|
||||
<div className="portal-pipelines__roster-name">
|
||||
<strong>{p.name}</strong>
|
||||
<span className="portal-pipelines__roster-route">
|
||||
{p.source} → {p.destination}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Health",
|
||||
render: (p) => (
|
||||
<StatusBadge
|
||||
tone={p.status === "degraded" ? "warning" : "success"}
|
||||
size="sm"
|
||||
pulse={p.status === "degraded"}
|
||||
>
|
||||
{p.status === "degraded" ? "Degraded" : "Healthy"}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "golden",
|
||||
header: "Golden set",
|
||||
width: "11rem",
|
||||
render: (p) => {
|
||||
const tone = goldenTone(p.golden);
|
||||
const rate = p.golden.total ? p.golden.passing / p.golden.total : 0;
|
||||
return (
|
||||
<div className="portal-pipelines__roster-golden">
|
||||
<StatusBadge tone={tone} size="sm">
|
||||
{p.golden.passing}/{p.golden.total}
|
||||
</StatusBadge>
|
||||
<span
|
||||
className="portal-pipelines__roster-rate"
|
||||
title={`Bound: ${pct(p.golden.threshold, 0)}`}
|
||||
>
|
||||
{pct(rate, 1)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "docs",
|
||||
header: "Docs / 24h",
|
||||
align: "right",
|
||||
render: (p) => (
|
||||
<span className="portal-pipelines__roster-num">
|
||||
{compact(p.metrics.docs24h)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "version",
|
||||
header: "Version",
|
||||
align: "right",
|
||||
render: (p) => (
|
||||
<span className="portal-pipelines__roster-version">{p.version}</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Table<Pipeline>
|
||||
className="portal-pipelines__roster"
|
||||
columns={columns}
|
||||
rows={pipelines}
|
||||
rowKey={(p) => p.id}
|
||||
onRowClick={onRowClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { PromotedPipelines } from "@portal/components/pipelines/PromotedPipelines";
|
||||
import { PROMOTED_PIPELINES } from "@portal/components/pipelines/storyFixtures";
|
||||
import "@portal/views/Pipelines.css";
|
||||
|
||||
const meta: Meta<typeof PromotedPipelines> = {
|
||||
title: "Portal/Pipelines/PromotedPipelines",
|
||||
component: PromotedPipelines,
|
||||
parameters: { layout: "padded" },
|
||||
args: { promoted: PROMOTED_PIPELINES },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "72rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PromotedPipelines>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: { promoted: [] },
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
StatusBadge,
|
||||
type StatusTone,
|
||||
Table,
|
||||
type TableColumn,
|
||||
} from "@shared/components";
|
||||
import {
|
||||
promoteToPolicy,
|
||||
type PromotedPipeline,
|
||||
type PromotedStatus,
|
||||
} from "@portal/api/pipelines";
|
||||
|
||||
const STATUS_TONE: Record<PromotedStatus, StatusTone> = {
|
||||
deployed: "success",
|
||||
staged: "info",
|
||||
review: "warning",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<PromotedStatus, string> = {
|
||||
deployed: "Deployed",
|
||||
staged: "Staged",
|
||||
review: "Needs review",
|
||||
};
|
||||
|
||||
/** Per-row promote-to-policy lifecycle, kept local until a backend exists. */
|
||||
type PromoteState = "idle" | "pending" | "done";
|
||||
|
||||
interface PromotedPipelinesProps {
|
||||
promoted: PromotedPipeline[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Flows that started as Editor watch-folder automations and were promoted into
|
||||
* the portal. Each keeps a pointer back to the watch folder it grew from, and
|
||||
* offers a one-click path to lift its rules into a fleet-wide org policy.
|
||||
*/
|
||||
export function PromotedPipelines({ promoted }: PromotedPipelinesProps) {
|
||||
// Promote submits have no backend yet, so reflect acceptance per row locally.
|
||||
const [promoteState, setPromoteState] = useState<
|
||||
Record<string, PromoteState>
|
||||
>({});
|
||||
|
||||
const onPromote = async (p: PromotedPipeline) => {
|
||||
setPromoteState((s) => ({ ...s, [p.id]: "pending" }));
|
||||
try {
|
||||
// TODO(backend): POST /v1/pipelines/{id}/promote-to-policy — stubbed,
|
||||
// resolves against the mock handler; treat success as accepted.
|
||||
await promoteToPolicy(p.id);
|
||||
setPromoteState((s) => ({ ...s, [p.id]: "done" }));
|
||||
} catch {
|
||||
setPromoteState((s) => ({ ...s, [p.id]: "idle" }));
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo<TableColumn<PromotedPipeline>[]>(
|
||||
() => [
|
||||
{
|
||||
key: "name",
|
||||
header: "Pipeline",
|
||||
render: (p) => (
|
||||
<div className="portal-pipelines__promoted-name">
|
||||
<strong>{p.name}</strong>
|
||||
<span className="portal-pipelines__promoted-when">
|
||||
{p.promotedAt}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "docType",
|
||||
header: "Source doc type",
|
||||
render: (p) => (
|
||||
<span className="portal-pipelines__promoted-muted">
|
||||
{p.sourceDocType}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "watchFolder",
|
||||
header: "Watch folder",
|
||||
render: (p) => (
|
||||
<code className="portal-pipelines__promoted-folder">
|
||||
{p.watchFolder}
|
||||
</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
render: (p) => (
|
||||
<StatusBadge tone={STATUS_TONE[p.status]} size="sm">
|
||||
{STATUS_LABEL[p.status]}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "promote",
|
||||
header: "",
|
||||
align: "right",
|
||||
width: "11rem",
|
||||
render: (p) => {
|
||||
const state = promoteState[p.id] ?? "idle";
|
||||
if (state === "done") {
|
||||
return (
|
||||
<StatusBadge tone="success" size="sm">
|
||||
Policy created
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={state === "pending"}
|
||||
onClick={() => onPromote(p)}
|
||||
>
|
||||
Promote to policy
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[promoteState],
|
||||
);
|
||||
|
||||
return (
|
||||
<Table<PromotedPipeline>
|
||||
className="portal-pipelines__promoted"
|
||||
columns={columns}
|
||||
rows={promoted}
|
||||
rowKey={(p) => p.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { GoldenSet } from "@portal/api/pipelines";
|
||||
import type { StatusTone } from "@shared/components";
|
||||
|
||||
/** Fraction → percentage string (0.004 → "0.40%"). */
|
||||
export const pct = (n: number, digits = 1) => `${(n * 100).toFixed(digits)}%`;
|
||||
|
||||
@@ -7,3 +10,17 @@ export const compact = (n: number) =>
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(n);
|
||||
|
||||
/**
|
||||
* Golden-set reliability tone, judged against the pipeline's own pass-rate
|
||||
* bound. At/above bound is green; a small slip under is amber; a clear miss is
|
||||
* danger — so a row's reliability reads from colour alone.
|
||||
*/
|
||||
export function goldenTone(golden: GoldenSet): StatusTone {
|
||||
if (golden.total === 0) return "neutral";
|
||||
const rate = golden.passing / golden.total;
|
||||
if (rate >= golden.threshold) return "success";
|
||||
// Within five points of the bound is a warning; further off is a hard miss.
|
||||
if (rate >= golden.threshold - 0.05) return "warning";
|
||||
return "danger";
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Pipeline } from "@portal/api/pipelines";
|
||||
import type { Pipeline, PromotedPipeline } from "@portal/api/pipelines";
|
||||
|
||||
/** Sample pipelines shared by the Pipelines component stories. */
|
||||
|
||||
@@ -33,7 +33,7 @@ export const HEALTHY_PIPELINE: Pipeline = {
|
||||
},
|
||||
{ key: "route", label: "Route / Store", ops: ["Primary store", "Notify"] },
|
||||
],
|
||||
golden: { passing: 42, total: 42, lastRun: "1h ago" },
|
||||
golden: { passing: 42, total: 42, lastRun: "1h ago", threshold: 0.95 },
|
||||
drift: [],
|
||||
};
|
||||
|
||||
@@ -54,7 +54,7 @@ export const DEGRADED_PIPELINE: Pipeline = {
|
||||
p95LatencyMs: 740,
|
||||
uptime: 0.9962,
|
||||
},
|
||||
golden: { passing: 24, total: 28, lastRun: "47m ago" },
|
||||
golden: { passing: 24, total: 28, lastRun: "47m ago", threshold: 0.9 },
|
||||
drift: [
|
||||
{
|
||||
field: "procedure_codes",
|
||||
@@ -72,3 +72,31 @@ export const DEGRADED_PIPELINE: Pipeline = {
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Sample watch-folder-promoted flows for the PromotedPipelines stories. */
|
||||
export const PROMOTED_PIPELINES: PromotedPipeline[] = [
|
||||
{
|
||||
id: "pl-promo-statements",
|
||||
name: "Bank Statement Normalizer",
|
||||
sourceDocType: "Bank statement",
|
||||
watchFolder: "~/StirlingWatch/statements-in",
|
||||
status: "deployed",
|
||||
promotedAt: "promoted 3d ago",
|
||||
},
|
||||
{
|
||||
id: "pl-promo-receipts",
|
||||
name: "Receipt Splitter",
|
||||
sourceDocType: "Expense receipt",
|
||||
watchFolder: "~/StirlingWatch/receipts",
|
||||
status: "staged",
|
||||
promotedAt: "promoted 11h ago",
|
||||
},
|
||||
{
|
||||
id: "pl-promo-onboarding",
|
||||
name: "New-Hire Packet Sorter",
|
||||
sourceDocType: "Onboarding packet",
|
||||
watchFolder: "\\\\hr-share\\NewHireScans",
|
||||
status: "review",
|
||||
promotedAt: "promoted 2h ago",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { PoliciesResponse } from "@portal/api/policies";
|
||||
import { CatalogueSummary } from "@portal/components/policies/CatalogueSummary";
|
||||
|
||||
const RESPONSE: PoliciesResponse = {
|
||||
summary: { active: 1, paused: 0, categories: 5, docsEnforced: 4821 },
|
||||
catalogue: [],
|
||||
};
|
||||
|
||||
const meta: Meta<typeof CatalogueSummary> = {
|
||||
title: "Portal/Policies/CatalogueSummary",
|
||||
component: CatalogueSummary,
|
||||
parameters: { layout: "padded" },
|
||||
args: { data: RESPONSE, loading: false },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CatalogueSummary>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
/** No data yet — every tile shows the em-dash placeholder. */
|
||||
export const Loading: Story = { args: { data: null, loading: true } };
|
||||
@@ -0,0 +1,40 @@
|
||||
import { MetricCard, MetricStrip } from "@shared/components";
|
||||
import type { PoliciesResponse } from "@portal/api/policies";
|
||||
|
||||
interface CatalogueSummaryProps {
|
||||
data: PoliciesResponse | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary strip above the catalogue. Labels are product copy (they describe
|
||||
* what each metric is, not its value) so the strip's structure stays stable
|
||||
* across loading / ready states; only the values flow from the API.
|
||||
*/
|
||||
export function CatalogueSummary({ data, loading }: CatalogueSummaryProps) {
|
||||
const s = loading ? undefined : data?.summary;
|
||||
return (
|
||||
<MetricStrip>
|
||||
<MetricCard
|
||||
label="Active policies"
|
||||
value={s ? s.active : "—"}
|
||||
description="Enforcing on upload/export"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Paused"
|
||||
value={s ? s.paused : "—"}
|
||||
description="Configured but not firing"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Categories"
|
||||
value={s ? s.categories : "—"}
|
||||
description="Available to configure"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Docs enforced"
|
||||
value={s ? s.docsEnforced.toLocaleString() : "—"}
|
||||
description="Across active policies"
|
||||
/>
|
||||
</MetricStrip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import {
|
||||
POLICY_CATEGORIES,
|
||||
POLICY_CONFIG,
|
||||
decorateForStory,
|
||||
} from "@portal/components/policies/storyFixtures";
|
||||
import { PolicyCategoryCard } from "@portal/components/policies/PolicyCategoryCard";
|
||||
|
||||
const security = POLICY_CATEGORIES.find((c) => c.id === "security")!;
|
||||
const compliance = POLICY_CATEGORIES.find((c) => c.id === "compliance")!;
|
||||
|
||||
const meta: Meta<typeof PolicyCategoryCard> = {
|
||||
title: "Portal/Policies/PolicyCategoryCard",
|
||||
component: PolicyCategoryCard,
|
||||
parameters: { layout: "padded" },
|
||||
args: { onOpen: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PolicyCategoryCard>;
|
||||
|
||||
/** A configured, active policy — shows live stats. */
|
||||
export const Configured: Story = {
|
||||
args: {
|
||||
entry: {
|
||||
category: security,
|
||||
config: POLICY_CONFIG.security,
|
||||
policy: decorateForStory("security"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Not yet set up — shows the rule chips + "Set up" affordance. */
|
||||
export const NotSetUp: Story = {
|
||||
args: {
|
||||
entry: { category: security, config: POLICY_CONFIG.security, policy: null },
|
||||
},
|
||||
};
|
||||
|
||||
/** Coming-soon category — locked and inert. */
|
||||
export const ComingSoon: Story = {
|
||||
args: {
|
||||
entry: {
|
||||
category: compliance,
|
||||
config: POLICY_CONFIG.compliance,
|
||||
policy: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user