mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Portal home: cut the mock blocks, wire the rest to real data (#6931)
## What this changes Cleaning up the portal home page. Most of what was under the plan card was mock — it hit endpoints that don't exist on any backend, so on SaaS it just showed a wall of "—" and "Nothing here yet". I removed the fake stuff and wired the bits worth keeping to real data. **Scrapped (all mock, no backend anywhere — self-hosted included):** - The "No usage yet" usage chart - The KPI strip (Docs/30d, Pipelines, Agents active, Eval pass rate) - The "Build a pipeline in seconds" fork wizard (fake build animation, deploy was a TODO) - The Sources/Pipelines/Agents product cards - The "Popular use cases" marketing cards - Enterprise region health - The "Try a PDF operation" runner Deleted their component/api/mock/MSW/story files too, plus the now-dead i18n keys and CSS. **Wired to real data (finished, not removed):** - The plan strip and the sidebar footer now show the **real** 30-day processed-PDF count from `/api/v1/usage/fleet-stats` (was a mock KPI). Shows "—" honestly when the backend can't compute it, never a fake number. - **Recent activity** now reads the **real audit log** — the same endpoint the Infrastructure → Audit tab uses. **Result:** one simple layout for all tiers — plan strip → recent activity + quick actions → "What runs on your PDFs" (the real policy summary). Every block is backed by data that actually exists. Net: **−3,221 / +89 lines**, 17 files removed. ## Testing typecheck (all variants), full test suite (1211), saas + proprietary builds, lint, format, storybook build, toml-sort — all green. The `unusedTranslations` test guarantees no orphaned i18n keys were left behind.
This commit is contained in:
@@ -6791,23 +6791,6 @@ description = "This view hit an unexpected error. Try again, or pick another sec
|
||||
retry = "Try again"
|
||||
title = "Something went wrong on this page"
|
||||
|
||||
[portal.forkWizard]
|
||||
subtitle = "Clone a proven workflow and tune it — every template ships the same four-stage backbone."
|
||||
title = "Fork a starter pipeline"
|
||||
|
||||
[portal.forkWizard.action]
|
||||
cancel = "Cancel"
|
||||
deploy = "Deploy pipeline"
|
||||
pickAnother = "Pick another"
|
||||
|
||||
[portal.forkWizard.status]
|
||||
building = "Building…"
|
||||
ready = "Ready to deploy"
|
||||
|
||||
[portal.home.chart.empty]
|
||||
description = "Once documents are processed, your 30-day usage appears here."
|
||||
title = "No usage yet"
|
||||
|
||||
[portal.home.editor]
|
||||
activeUsers = "{{n}} active"
|
||||
invite = "Invite teammates"
|
||||
@@ -6825,25 +6808,6 @@ afternoon = "Good afternoon"
|
||||
evening = "Good evening"
|
||||
morning = "Good morning"
|
||||
|
||||
[portal.home.kpis.enterprise]
|
||||
docs30d = "Docs / 30d"
|
||||
evalPassRate = "Eval pass rate"
|
||||
p95Latency = "P95 latency"
|
||||
slaUptime = "SLA uptime (30d)"
|
||||
|
||||
[portal.home.kpis.free]
|
||||
agents = "Agents"
|
||||
docsProcessed = "Docs processed"
|
||||
docsProcessedDescription = "Free plan cap"
|
||||
operations = "Operations"
|
||||
pipelines = "Pipelines"
|
||||
|
||||
[portal.home.kpis.pro]
|
||||
agentsActive = "Agents active"
|
||||
docs30d = "Docs / 30d"
|
||||
evalPassRate = "Eval pass rate"
|
||||
pipelines = "Pipelines"
|
||||
|
||||
[portal.home.onboarding]
|
||||
dismiss = "Dismiss setup"
|
||||
notStarted = "Not started"
|
||||
@@ -6871,25 +6835,6 @@ blurb = "{{connected}} connected · every PDF governed where it lands"
|
||||
chip = "{{connected}} connected"
|
||||
title = "Connect your sources"
|
||||
|
||||
[portal.home.productGrid]
|
||||
ariaLabel = "Process PDFs at scale"
|
||||
|
||||
[portal.home.productGrid.agents]
|
||||
blurb = "Wire your agent via MCP, REST, or tool definitions. Deterministic operations, scenarios, evals."
|
||||
cta = "Connect an agent"
|
||||
title = "Agents"
|
||||
|
||||
[portal.home.productGrid.pipelines]
|
||||
badge = "Hero"
|
||||
blurb = "Compose document workflows from typed operations. Upload a sample to get suggestions or start blank."
|
||||
cta = "Build a pipeline"
|
||||
title = "Pipelines"
|
||||
|
||||
[portal.home.productGrid.sources]
|
||||
blurb = "Attach pipelines where PDFs already live — S3, agents, SharePoint, webhooks, batch, email."
|
||||
cta = "Connect a source"
|
||||
title = "Sources"
|
||||
|
||||
[portal.home.quickActions]
|
||||
subtitle = "Top tasks for today"
|
||||
title = "Quick actions"
|
||||
@@ -6906,18 +6851,6 @@ title = "Connect a source"
|
||||
blurb = "Scoped key with rate limits and IP allowlist"
|
||||
title = "Issue an API key"
|
||||
|
||||
[portal.home.quickActions.tryOp]
|
||||
blurb = "Drop a sample, pick an op, see the JSON"
|
||||
title = "Try a PDF operation"
|
||||
|
||||
[portal.home.regions]
|
||||
subtitle = "Real-time status for every deployed Stirling region."
|
||||
title = "Region health"
|
||||
|
||||
[portal.home.regions.empty]
|
||||
description = "Once a region is deployed, its health appears here."
|
||||
title = "No regions yet"
|
||||
|
||||
[portal.infrastructure]
|
||||
manageEditorDeployment = "Manage Editor deployment"
|
||||
sectionsAriaLabel = "Infrastructure sections"
|
||||
@@ -7270,51 +7203,6 @@ new_other = "{{count}} new"
|
||||
description = "No new notifications."
|
||||
title = "You're all caught up"
|
||||
|
||||
[portal.opRunner]
|
||||
durationMs = "{{ms}} ms"
|
||||
featuredOps = "Featured ops"
|
||||
subtitle = "Drop a sample, pick an op, see what Stirling returns."
|
||||
title = "Try a PDF operation"
|
||||
|
||||
[portal.opRunner.action]
|
||||
close = "Close"
|
||||
openBuilder = "Open the pipeline builder"
|
||||
run = "Run operation"
|
||||
runAgain = "Run again"
|
||||
running = "Running…"
|
||||
|
||||
[portal.opRunner.drop]
|
||||
hint = "or use a sample document."
|
||||
pickAnother = "Pick another sample"
|
||||
replaceHint = "Drop again or pick another sample to replace."
|
||||
title = "Drop a PDF here"
|
||||
useSample = "Use a sample"
|
||||
|
||||
[portal.opRunner.empty]
|
||||
description = "Once operations are published, they'll show up here."
|
||||
title = "No featured ops yet"
|
||||
|
||||
[portal.opRunner.error]
|
||||
title = "The operation didn't complete"
|
||||
unknown = "Unknown error"
|
||||
|
||||
[portal.opRunner.hint]
|
||||
aSample = "a sample"
|
||||
press = "Press"
|
||||
ready = "Ready"
|
||||
toInvoke = "to invoke"
|
||||
|
||||
[portal.opRunner.hint.runOn]
|
||||
before = "Run"
|
||||
middle = "on"
|
||||
|
||||
[portal.opRunner.running]
|
||||
title = "Running {{label}}…"
|
||||
|
||||
[portal.opRunner.status]
|
||||
completed = "Completed"
|
||||
failed = "Failed"
|
||||
|
||||
[portal.pipelines]
|
||||
subtitle = "Every automated document pipeline on the backend: an ordered chain of operations over a set of sources, run on a trigger. Click a row for its steps and sources."
|
||||
title = "Pipelines"
|
||||
@@ -7573,9 +7461,6 @@ soon = "Soon"
|
||||
|
||||
[portal.processingStatus]
|
||||
managePlan = "Manage plan"
|
||||
pdfsThisMonth = "PDFs this month"
|
||||
progressLabel = "{{used}} of {{cap}} PDFs used this month"
|
||||
upgrade = "Upgrade"
|
||||
volumeSuffix = "PDFs processed · last 30 days"
|
||||
|
||||
[portal.procurement]
|
||||
@@ -7944,8 +7829,8 @@ appEditor = "Editor"
|
||||
appProcessor = "Processor"
|
||||
brandSuffix = "Stirling Processor"
|
||||
docsCount = "{{docs}} docs"
|
||||
docsProcessed = "Docs processed"
|
||||
linkAccount = "Link Stirling account"
|
||||
planEditor = "Editor plan"
|
||||
planEnterprise = "Enterprise plan"
|
||||
planProcessor = "Processor plan"
|
||||
primaryNav = "Primary navigation"
|
||||
@@ -8053,40 +7938,6 @@ action = "Sign in again"
|
||||
body = "Your Stirling account session has expired. Sign in again to view billing — your instance stays linked."
|
||||
title = "Session expired"
|
||||
|
||||
[portal.usageChart]
|
||||
defaultLabel = "Docs processed · last 30 days"
|
||||
delta = "{{pct}}% vs prior 30d"
|
||||
docsValue = "{{value}} docs"
|
||||
srAnnounce = "{{date}}: {{value}} docs"
|
||||
|
||||
[portal.useCases]
|
||||
title = "Popular use cases"
|
||||
viewAll = "View all pipelines"
|
||||
|
||||
[portal.useCases.items.authenticity]
|
||||
blurb = "Cryptographic checks at the document boundary — signature validation, tamper detection, signing flows for outbound documents. Trust decisions in the pipeline, not your app code."
|
||||
cta = "Try authenticity check"
|
||||
eyebrow = "AUTHENTICITY"
|
||||
title = "Verify signatures and detect tampering"
|
||||
|
||||
[portal.useCases.items.autoRouting]
|
||||
blurb = "One classifier reads what arrived — KYC form, invoice, contract, COI — and routes to the right downstream pipeline. No manual triage, no docs in the wrong workflow."
|
||||
cta = "Build a classifier pipeline"
|
||||
eyebrow = "AUTO-ROUTING"
|
||||
title = "Auto-classify and route incoming documents"
|
||||
|
||||
[portal.useCases.items.piiRedaction]
|
||||
blurb = "Strip sensitive fields before storage, indexing, or LLM processing. Schema-aware, per-field audit, BYOK or HYOK keys. Compliance at the document boundary, not per pipeline."
|
||||
cta = "See redaction pipelines"
|
||||
eyebrow = "PII REDACTION"
|
||||
title = "Redact PII before it leaves your stack"
|
||||
|
||||
[portal.useCases.items.trainingData]
|
||||
blurb = "Batch-import an archive, redact PII, classify, chunk, and emit ready-to-load JSON for fine-tuning, eval sets, or RAG. Self-completing and replayable."
|
||||
cta = "Build a training-data pipeline"
|
||||
eyebrow = "TRAINING DATA"
|
||||
title = "Turn PDFs into training data"
|
||||
|
||||
[portal.users]
|
||||
title = "Users"
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type {
|
||||
ActivityEvent,
|
||||
KpiEntry,
|
||||
RegionHealth,
|
||||
UsageSeriesResponse,
|
||||
} from "@portal/mocks/home";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
export type {
|
||||
ActivityEvent,
|
||||
ActivityKind,
|
||||
KpiEntry,
|
||||
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> {
|
||||
return apiClient.local.json<UsageSeriesResponse>(
|
||||
"/v1/analytics/usage?window=30d",
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /v1/activity?limit=8 */
|
||||
export async function fetchRecentActivity(): Promise<ActivityEvent[]> {
|
||||
return apiClient.local.json<ActivityEvent[]>("/v1/activity?limit=8");
|
||||
}
|
||||
|
||||
/** GET /v1/home/kpis?tier=… */
|
||||
export async function fetchHomeKpis(tier: Tier): Promise<KpiEntry[]> {
|
||||
return apiClient.local.json<KpiEntry[]>(
|
||||
`/v1/home/kpis?tier=${encodeURIComponent(tier)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /v1/regions/health (Enterprise) */
|
||||
export async function fetchRegionHealth(): Promise<RegionHealth[]> {
|
||||
return apiClient.local.json<RegionHealth[]>("/v1/regions/health");
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { apiClient, HttpError } from "@portal/api/http";
|
||||
import type { FeaturedOp, OpResultMap } from "@portal/mocks/ops";
|
||||
|
||||
export type { FeaturedOp, OpResultMap };
|
||||
|
||||
/** GET /v1/ops/featured */
|
||||
export async function fetchFeaturedOps(): Promise<FeaturedOp[]> {
|
||||
return apiClient.local.json<FeaturedOp[]>("/v1/ops/featured");
|
||||
}
|
||||
|
||||
export class UnknownOpError extends Error {
|
||||
constructor(public readonly opId: string) {
|
||||
super(`Unknown op: ${opId}`);
|
||||
this.name = "UnknownOpError";
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /v1/ops/{opId}/run */
|
||||
export async function runSingleOp(
|
||||
opId: string,
|
||||
sample: string,
|
||||
): Promise<{ result: OpResultMap; durationMs: number }> {
|
||||
try {
|
||||
return await apiClient.local.json<{
|
||||
result: OpResultMap;
|
||||
durationMs: number;
|
||||
}>(`/v1/ops/${encodeURIComponent(opId)}/run`, {
|
||||
method: "POST",
|
||||
body: { sample },
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError && err.status === 404) {
|
||||
throw new UnknownOpError(opId);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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 = {};
|
||||
@@ -1,162 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Card, Chip, StatusBadge } from "@app/ui";
|
||||
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 { t } = useTranslation();
|
||||
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">{t("portal.forkWizard.title")}</h2>
|
||||
<p className="portal-fork__sub">{t("portal.forkWizard.subtitle")}</p>
|
||||
</div>
|
||||
{phase !== "pick" && template && (
|
||||
<StatusBadge tone={phase === "ready" ? "success" : "info"} size="sm">
|
||||
{phase === "ready"
|
||||
? t("portal.forkWizard.status.ready")
|
||||
: t("portal.forkWizard.status.building")}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{phase === "pick" && (
|
||||
<div className="portal-fork__templates">
|
||||
{PIPELINE_TEMPLATES.map((t) => (
|
||||
<Button
|
||||
key={t.id}
|
||||
variant="tertiary"
|
||||
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">
|
||||
{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="tertiary" size="sm" onClick={reset}>
|
||||
{phase === "ready"
|
||||
? t("portal.forkWizard.action.pickAnother")
|
||||
: t("portal.forkWizard.action.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={deploy}
|
||||
disabled={phase !== "ready"}
|
||||
rightSection={<span aria-hidden>→</span>}
|
||||
>
|
||||
{phase === "ready"
|
||||
? t("portal.forkWizard.action.deploy")
|
||||
: t("portal.forkWizard.status.building")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
.portal-usecases {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.portal-usecases__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-usecases__title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-usecases__viewall,
|
||||
.portal-usecases__cta {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
transition: opacity var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-usecases__viewall {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
.portal-usecases__viewall:hover,
|
||||
.portal-usecases__cta:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.portal-usecases__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
.portal-usecases__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.portal-usecases__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.portal-usecases__eyebrow {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.portal-usecases__card-title {
|
||||
margin: 0.125rem 0 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-usecases__blurb {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-usecases__cta {
|
||||
margin-top: 0.5rem;
|
||||
align-self: flex-start;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Card, type CardProps } from "@app/ui";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
import "@portal/components/PopularUseCases.css";
|
||||
|
||||
type Accent = NonNullable<CardProps["accent"]>;
|
||||
|
||||
interface UseCase {
|
||||
/** Stable key into the useCases.items.* translation table. */
|
||||
key: string;
|
||||
accent: Accent;
|
||||
}
|
||||
|
||||
const ACCENT_COLOR: Partial<Record<Accent, string>> = {
|
||||
default: "var(--color-blue)",
|
||||
premium: "var(--color-purple)",
|
||||
success: "var(--color-green)",
|
||||
warning: "var(--color-amber)",
|
||||
danger: "var(--color-red)",
|
||||
};
|
||||
|
||||
/**
|
||||
* Curated landing-page use cases — a teaser, not the full catalogue. The
|
||||
* exhaustive per-vertical endpoint list lives on the Documents view; here we
|
||||
* surface the four cross-cutting pipelines people reach for first. The display
|
||||
* copy (eyebrow, title, blurb, cta) is keyed into useCases.items.<key>.
|
||||
*/
|
||||
const USE_CASES: UseCase[] = [
|
||||
{ key: "autoRouting", accent: "default" },
|
||||
{ key: "piiRedaction", accent: "danger" },
|
||||
{ key: "trainingData", accent: "premium" },
|
||||
{ key: "authenticity", accent: "success" },
|
||||
];
|
||||
|
||||
export function PopularUseCases() {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useView();
|
||||
return (
|
||||
<section
|
||||
className="portal-usecases"
|
||||
aria-label={t("portal.useCases.title")}
|
||||
>
|
||||
<header className="portal-usecases__head">
|
||||
<h2 className="portal-usecases__title">{t("portal.useCases.title")}</h2>
|
||||
<Button
|
||||
variant="quiet"
|
||||
type="button"
|
||||
className="portal-usecases__viewall"
|
||||
onClick={() => setActiveView("pipelines")}
|
||||
>
|
||||
{t("portal.useCases.viewAll")} <span aria-hidden>→</span>
|
||||
</Button>
|
||||
</header>
|
||||
<div className="portal-usecases__grid">
|
||||
{USE_CASES.map((uc) => (
|
||||
<Card
|
||||
key={uc.key}
|
||||
accent={uc.accent}
|
||||
padding="loose"
|
||||
className="portal-usecases__card"
|
||||
>
|
||||
<span
|
||||
className="portal-usecases__eyebrow"
|
||||
style={{ color: ACCENT_COLOR[uc.accent] }}
|
||||
>
|
||||
{t(`portal.useCases.items.${uc.key}.eyebrow`)}
|
||||
</span>
|
||||
<h3 className="portal-usecases__card-title">
|
||||
{t(`portal.useCases.items.${uc.key}.title`)}
|
||||
</h3>
|
||||
<p className="portal-usecases__blurb">
|
||||
{t(`portal.useCases.items.${uc.key}.blurb`)}
|
||||
</p>
|
||||
<Button
|
||||
variant="quiet"
|
||||
type="button"
|
||||
className="portal-usecases__cta"
|
||||
style={{ color: ACCENT_COLOR[uc.accent] }}
|
||||
onClick={() => setActiveView("pipelines")}
|
||||
>
|
||||
{t(`portal.useCases.items.${uc.key}.cta`)}{" "}
|
||||
<span aria-hidden>→</span>
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +1,4 @@
|
||||
.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 */
|
||||
/* Single inline row: plan + real processed-PDF volume + manage-plan shortcut. */
|
||||
.portal-statusstrip--paid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,90 +1,23 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button, ProgressBar, Skeleton } from "@app/ui";
|
||||
import { Button, Skeleton } from "@app/ui";
|
||||
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 { fetchFleetStats, type FleetStats } from "@portal/api/fleetStats";
|
||||
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.
|
||||
* A thin one-line header above the home content: current plan + the real 30-day
|
||||
* processed-PDF volume (from the fleet-usage endpoint), with a shortcut to the
|
||||
* Usage page. Renders "—" while loading or when the backend can't compute the
|
||||
* figure (e.g. EE auditing disabled) — never a fabricated number.
|
||||
*/
|
||||
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 { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
const { setActiveView } = useView();
|
||||
const { data: kpis, loading } = useAsync<KpiEntry[]>(
|
||||
() => fetchHomeKpis(tier),
|
||||
[tier],
|
||||
);
|
||||
const { data, loading } = useAsync<FleetStats>(() => fetchFleetStats(), []);
|
||||
|
||||
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="secondary"
|
||||
onClick={() => setActiveView("usage")}
|
||||
>
|
||||
{t("portal.processingStatus.upgrade")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="portal-statusstrip__free-row">
|
||||
<span className="portal-statusstrip__free-label">
|
||||
<strong>{used.toLocaleString()}</strong> / {cap.toLocaleString()}{" "}
|
||||
{t("portal.processingStatus.pdfsThisMonth")}
|
||||
</span>
|
||||
<span className="portal-statusstrip__free-pct">
|
||||
{Math.round(ratio * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={ratio}
|
||||
thresholded
|
||||
label={t("portal.processingStatus.progressLabel", { used, cap })}
|
||||
/>
|
||||
</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">
|
||||
@@ -99,7 +32,11 @@ export function ProcessingStatusStrip() {
|
||||
·
|
||||
</span>
|
||||
<span className="portal-statusstrip__volume">
|
||||
<strong>{volume ?? "—"}</strong>{" "}
|
||||
{loading ? (
|
||||
<Skeleton width="3rem" height="0.875rem" />
|
||||
) : (
|
||||
<strong>{data?.pdfsProcessed?.toLocaleString() ?? "—"}</strong>
|
||||
)}{" "}
|
||||
{t("portal.processingStatus.volumeSuffix")}
|
||||
</span>
|
||||
<Button
|
||||
|
||||
@@ -1,27 +1,49 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Card, EmptyState, Skeleton, StatusBadge } from "@app/ui";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import {
|
||||
fetchRecentActivity,
|
||||
type ActivityEvent,
|
||||
type ActivityKind,
|
||||
} from "@portal/api/home";
|
||||
fetchAuditLog,
|
||||
type AuditLogResponse,
|
||||
type AuditStatus,
|
||||
} from "@portal/api/infrastructure";
|
||||
import {
|
||||
AUDIT_CAT_LABEL,
|
||||
AUDIT_STATUS_LABEL,
|
||||
AUDIT_TONE,
|
||||
} from "@portal/components/infrastructure/infraFormat";
|
||||
import "@portal/components/RecentActivity.css";
|
||||
|
||||
const KIND_COLOUR: Record<ActivityKind, string> = {
|
||||
"pipeline-run": "var(--color-blue)",
|
||||
deploy: "var(--color-green)",
|
||||
drift: "var(--color-amber)",
|
||||
eval: "var(--color-purple)",
|
||||
agent: "var(--color-cat-insurance)",
|
||||
billing: "var(--color-red)",
|
||||
/** Rail colour by outcome — mirrors the audit status tones. */
|
||||
const STATUS_COLOUR: Record<AuditStatus, string> = {
|
||||
success: "var(--color-green)",
|
||||
warning: "var(--color-amber)",
|
||||
danger: "var(--color-red)",
|
||||
info: "var(--color-blue)",
|
||||
};
|
||||
|
||||
/** How many of the most recent audit events the home card shows. */
|
||||
const MAX_EVENTS = 6;
|
||||
|
||||
/**
|
||||
* The home "Recent activity" card, backed by the real audit log (the same
|
||||
* endpoint the Infrastructure → Audit tab reads). Shows the latest few events;
|
||||
* "View all" jumps to the full audit view. Failures and an empty log both fall
|
||||
* through to the empty state — never an error banner on the dashboard.
|
||||
*/
|
||||
export function RecentActivity() {
|
||||
const { t } = useTranslation();
|
||||
const state = useAsync<ActivityEvent[]>(() => fetchRecentActivity(), []);
|
||||
const { data: events } = state;
|
||||
const { isLoading, isEmpty } = useSectionFlags(state);
|
||||
const { tier } = useTier();
|
||||
const { setActiveView } = useView();
|
||||
const { data, loading } = useAsync<AuditLogResponse>(
|
||||
() => fetchAuditLog(tier),
|
||||
[tier],
|
||||
);
|
||||
|
||||
const events = data?.events.slice(0, MAX_EVENTS) ?? [];
|
||||
const isLoading = loading && data === null;
|
||||
const isEmpty = !isLoading && events.length === 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -33,7 +55,12 @@ export function RecentActivity() {
|
||||
<h2 className="portal-activity__title">
|
||||
{t("portal.recentActivity.title")}
|
||||
</h2>
|
||||
<Button variant="quiet" type="button" className="portal-activity__more">
|
||||
<Button
|
||||
variant="quiet"
|
||||
type="button"
|
||||
className="portal-activity__more"
|
||||
onClick={() => setActiveView("infrastructure")}
|
||||
>
|
||||
{t("portal.recentActivity.viewAll")} →
|
||||
</Button>
|
||||
</header>
|
||||
@@ -63,13 +90,13 @@ export function RecentActivity() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{events && events.length > 0 && (
|
||||
{events.length > 0 && (
|
||||
<ol className="portal-activity__list">
|
||||
{events.map((event) => (
|
||||
<li key={event.id} className="portal-activity__item">
|
||||
<span
|
||||
className="portal-activity__rail"
|
||||
style={{ background: KIND_COLOUR[event.kind] }}
|
||||
style={{ background: STATUS_COLOUR[event.status] }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="portal-activity__body">
|
||||
@@ -77,15 +104,17 @@ export function RecentActivity() {
|
||||
<span className="portal-activity__action">
|
||||
{event.action}
|
||||
</span>
|
||||
<span className="portal-activity__time">{event.time}</span>
|
||||
<span className="portal-activity__time">
|
||||
{event.timestamp}
|
||||
</span>
|
||||
</div>
|
||||
<div className="portal-activity__subject">{event.subject}</div>
|
||||
<div className="portal-activity__subject">{event.target}</div>
|
||||
<div className="portal-activity__detail-row">
|
||||
<span className="portal-activity__detail">
|
||||
{event.detail}
|
||||
{AUDIT_CAT_LABEL[event.category]} · {event.actor}
|
||||
</span>
|
||||
<StatusBadge tone={event.status} size="sm">
|
||||
{event.status}
|
||||
<StatusBadge tone={AUDIT_TONE[event.status]} size="sm">
|
||||
{AUDIT_STATUS_LABEL[event.status]}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -91,29 +91,11 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-sidebar__usage-label {
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-sidebar__usage-value {
|
||||
color: var(--color-usage-value);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.portal-sidebar__usage-track {
|
||||
margin-top: 0.5rem;
|
||||
height: 0.25rem;
|
||||
background: var(--color-usage-track);
|
||||
border-radius: var(--radius-pill);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.portal-sidebar__usage-fill {
|
||||
height: 100%;
|
||||
background: var(--grad-blue-btn);
|
||||
transition: width var(--motion-slow);
|
||||
}
|
||||
|
||||
.portal-sidebar__plan {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useTheme } from "@portal/contexts/ThemeContext";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import { fetchHomeKpis, type KpiEntry } from "@portal/api/home";
|
||||
import { fetchFleetStats, type FleetStats } from "@portal/api/fleetStats";
|
||||
import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl";
|
||||
import markLight from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg";
|
||||
import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg";
|
||||
@@ -24,48 +24,17 @@ import "@portal/components/Sidebar.css";
|
||||
function UsageFooter() {
|
||||
const { tier } = useTier();
|
||||
const { t } = useTranslation();
|
||||
// Read the same endpoint Home's KPI strip uses so the doc count here can't
|
||||
// drift from the headline figure. The first KPI is always the doc total.
|
||||
const { data: kpis, loading } = useAsync<KpiEntry[]>(
|
||||
() => fetchHomeKpis(tier),
|
||||
[tier],
|
||||
);
|
||||
const docs = loading ? undefined : kpis?.[0]?.value;
|
||||
|
||||
if (tier === "free") {
|
||||
// The free doc KPI is formatted "used / cap"; parse it for the meter.
|
||||
const [used, cap] =
|
||||
typeof docs === "string"
|
||||
? docs.split("/").map((s) => Number(s.replace(/[^\d]/g, "")))
|
||||
: [];
|
||||
const pct = used && cap ? (used / cap) * 100 : 0;
|
||||
return (
|
||||
<div className="portal-sidebar__usage portal-sidebar__usage--free">
|
||||
<div className="portal-sidebar__usage-line">
|
||||
<span className="portal-sidebar__usage-label">
|
||||
{t("portal.shell.sidebar.docsProcessed")}
|
||||
</span>
|
||||
<span className="portal-sidebar__usage-value">{docs ?? "—"}</span>
|
||||
</div>
|
||||
<div
|
||||
className="portal-sidebar__usage-track"
|
||||
role="progressbar"
|
||||
aria-valuenow={used ?? 0}
|
||||
aria-valuemax={cap ?? 100}
|
||||
>
|
||||
<div
|
||||
className="portal-sidebar__usage-fill"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Real 30-day processed-PDF count from the fleet-usage endpoint (the same
|
||||
// source as Home's status strip, so the two can't drift). null → "—".
|
||||
const { data, loading } = useAsync<FleetStats>(() => fetchFleetStats(), []);
|
||||
const docs = loading ? undefined : (data?.pdfsProcessed ?? undefined);
|
||||
|
||||
const planLabel =
|
||||
tier === "pro"
|
||||
? t("portal.shell.sidebar.planProcessor", "Processor plan")
|
||||
: t("portal.shell.sidebar.planEnterprise", "Enterprise plan");
|
||||
tier === "free"
|
||||
? t("portal.shell.sidebar.planEditor", "Editor plan")
|
||||
: tier === "pro"
|
||||
? t("portal.shell.sidebar.planProcessor", "Processor plan")
|
||||
: t("portal.shell.sidebar.planEnterprise", "Enterprise plan");
|
||||
|
||||
return (
|
||||
<div className="portal-sidebar__usage">
|
||||
|
||||
@@ -1,345 +0,0 @@
|
||||
.portal-runner__layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
|
||||
gap: 1rem;
|
||||
min-height: 26rem;
|
||||
}
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
.portal-runner__layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.portal-runner__left,
|
||||
.portal-runner__right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Drop zone */
|
||||
.portal-runner__drop {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 1.25rem;
|
||||
border: 1.5px dashed var(--color-border-input);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-3);
|
||||
text-align: center;
|
||||
transition:
|
||||
border-color var(--motion-fast),
|
||||
background var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-runner__drop.is-armed,
|
||||
.portal-runner__drop:hover {
|
||||
border-color: var(--color-blue);
|
||||
background: var(--color-blue-light);
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
.portal-runner__drop.has-file {
|
||||
border-color: var(--color-green);
|
||||
background: var(--color-green-light);
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.portal-runner__drop-icon {
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-runner__drop.is-armed .portal-runner__drop-icon,
|
||||
.portal-runner__drop.has-file .portal-runner__drop-icon {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.portal-runner__drop-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.portal-runner__drop-text strong {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-1);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.portal-runner__drop-text span {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-runner__sample-btn {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-blue);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-runner__sample-btn:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
/* Op picker */
|
||||
.portal-runner__section-title {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-section-label);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-runner__ops {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.portal-runner__op {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.125rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color var(--motion-fast),
|
||||
background var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-runner__op:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.portal-runner__op.is-selected {
|
||||
border-color: var(--color-blue);
|
||||
background: var(--color-blue-light);
|
||||
}
|
||||
|
||||
.portal-runner__op[data-accent="purple"].is-selected {
|
||||
border-color: var(--color-purple);
|
||||
background: var(--color-purple-light);
|
||||
}
|
||||
.portal-runner__op[data-accent="green"].is-selected {
|
||||
border-color: var(--color-green);
|
||||
background: var(--color-green-light);
|
||||
}
|
||||
.portal-runner__op[data-accent="amber"].is-selected {
|
||||
border-color: var(--color-amber);
|
||||
background: var(--color-amber-light);
|
||||
}
|
||||
.portal-runner__op[data-accent="red"].is-selected {
|
||||
border-color: var(--color-red);
|
||||
background: var(--color-red-light);
|
||||
}
|
||||
|
||||
.portal-runner__op-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-runner__op-endpoint {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-runner__op-blurb {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-3);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.portal-runner__op--skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
cursor: default;
|
||||
}
|
||||
.portal-runner__op--skeleton:hover {
|
||||
border-color: var(--color-border-light);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Right column states */
|
||||
.portal-runner__right {
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-subtle);
|
||||
padding: 1.125rem;
|
||||
overflow: hidden;
|
||||
min-height: 22rem;
|
||||
}
|
||||
|
||||
.portal-runner__hint {
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-runner__hint-eyebrow {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-blue);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-runner__hint h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-runner__hint h3 code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-blue);
|
||||
background: var(--color-blue-light);
|
||||
padding: 0 0.25rem;
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
|
||||
.portal-runner__hint p {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.portal-runner__hint p code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-2);
|
||||
background: var(--color-bg-muted);
|
||||
padding: 0 0.25rem;
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
|
||||
.portal-runner__hint kbd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
background: var(--color-bg-muted);
|
||||
padding: 0.0625rem 0.3125rem;
|
||||
border-radius: var(--radius-xs);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Running state */
|
||||
.portal-runner__running {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--color-blue-light);
|
||||
border: 1px solid var(--color-blue-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
.portal-runner__running-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.portal-runner__running code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.portal-runner__spinner,
|
||||
.portal-runner__spinner-lg {
|
||||
border-radius: 50%;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
animation: spin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.portal-runner__spinner {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
|
||||
.portal-runner__spinner-lg {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
/* Done state */
|
||||
.portal-runner__result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.portal-runner__result-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--code-bg-header);
|
||||
border-bottom: 1px solid var(--code-border);
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
color: var(--code-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.portal-runner__result-head code {
|
||||
flex: 1;
|
||||
font-family: inherit;
|
||||
color: var(--code-keyword);
|
||||
}
|
||||
|
||||
.portal-runner__result-meta {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--code-dim);
|
||||
}
|
||||
|
||||
.portal-runner__result-code {
|
||||
margin: 0;
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--code-bg);
|
||||
color: var(--code-text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.6;
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
max-height: 22rem;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.portal-runner__footer-status {
|
||||
margin-right: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.portal-runner__footer-status code {
|
||||
font-family: inherit;
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import { SingleOpRunner } from "@portal/components/SingleOpRunner";
|
||||
|
||||
const meta: Meta<typeof SingleOpRunner> = {
|
||||
title: "Portal/Home/SingleOpRunner",
|
||||
component: SingleOpRunner,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: { open: true, onClose: () => console.log("close") },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SingleOpRunner>;
|
||||
|
||||
export const Idle: Story = {};
|
||||
|
||||
export const PreSelectedOp: Story = {
|
||||
args: { initialOpId: "redact" },
|
||||
};
|
||||
|
||||
export const RunFailsWithUnknownOp: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.post("/v1/ops/:opId/run", () =>
|
||||
HttpResponse.json({ error: "Unknown op" }, { status: 404 }),
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SlowOp: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.post("/v1/ops/:opId/run", async () => {
|
||||
await delay(4000);
|
||||
return HttpResponse.json({
|
||||
result: { schema: "demo", note: "took its time" },
|
||||
durationMs: 4000,
|
||||
});
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,369 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, EmptyState, Modal, Skeleton, StatusBadge } from "@app/ui";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
import {
|
||||
fetchFeaturedOps,
|
||||
runSingleOp,
|
||||
type FeaturedOp,
|
||||
type OpResultMap,
|
||||
} from "@portal/api/ops";
|
||||
import "@portal/components/SingleOpRunner.css";
|
||||
|
||||
type Phase = "idle" | "running" | "done" | "error";
|
||||
|
||||
interface SingleOpRunnerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Optional pre-selected op id (e.g. carousel deep-link). */
|
||||
initialOpId?: string;
|
||||
}
|
||||
|
||||
const SAMPLE_DOCS = [
|
||||
"certificate-of-insurance.pdf",
|
||||
"loss-run-2025.pdf",
|
||||
"invoice-acme-corp-q1.pdf",
|
||||
"prior-auth-cigna-12471.pdf",
|
||||
"contract-acme-msa-v3.pdf",
|
||||
];
|
||||
|
||||
interface RunResult {
|
||||
result: OpResultMap;
|
||||
durationMs: number;
|
||||
opId: string;
|
||||
}
|
||||
|
||||
export function SingleOpRunner({
|
||||
open,
|
||||
onClose,
|
||||
initialOpId,
|
||||
}: SingleOpRunnerProps) {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useView();
|
||||
const opsState = useAsync<FeaturedOp[]>(() => fetchFeaturedOps(), []);
|
||||
const { data: ops } = opsState;
|
||||
const { isLoading: opsIsLoading, isEmpty: opsIsEmpty } =
|
||||
useSectionFlags(opsState);
|
||||
|
||||
const [selectedOpId, setSelectedOpId] = useState<string | null>(
|
||||
initialOpId ?? null,
|
||||
);
|
||||
const [phase, setPhase] = useState<Phase>("idle");
|
||||
const [sample, setSample] = useState<string | null>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [runResult, setRunResult] = useState<RunResult | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
// Default selection once the catalogue loads.
|
||||
useEffect(() => {
|
||||
if (!selectedOpId && ops && ops.length > 0) {
|
||||
setSelectedOpId(ops[0].id);
|
||||
}
|
||||
}, [ops, selectedOpId]);
|
||||
|
||||
const selectedOp = useMemo<FeaturedOp | null>(
|
||||
() => ops?.find((o) => o.id === selectedOpId) ?? null,
|
||||
[ops, selectedOpId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedOpId(initialOpId ?? ops?.[0]?.id ?? null);
|
||||
setPhase("idle");
|
||||
setSample(null);
|
||||
setRunResult(null);
|
||||
setErrorMsg(null);
|
||||
}
|
||||
// ops is intentionally not in deps — we don't want to reset state when
|
||||
// the catalogue arrives while the modal is open.
|
||||
}, [open, initialOpId]);
|
||||
|
||||
const pickSample = useCallback(() => {
|
||||
if (!ops) return;
|
||||
const idx = ops.findIndex((o) => o.id === selectedOpId);
|
||||
const safeIdx = idx < 0 ? 0 : idx;
|
||||
setSample(SAMPLE_DOCS[safeIdx % SAMPLE_DOCS.length]);
|
||||
}, [ops, selectedOpId]);
|
||||
|
||||
async function run() {
|
||||
if (!selectedOp) return;
|
||||
let activeSample = sample;
|
||||
if (!activeSample) {
|
||||
pickSample();
|
||||
// pickSample updates state; use the synchronously-derived value for the
|
||||
// call so we don't race.
|
||||
const idx = ops?.findIndex((o) => o.id === selectedOpId) ?? 0;
|
||||
activeSample = SAMPLE_DOCS[idx % SAMPLE_DOCS.length];
|
||||
}
|
||||
setPhase("running");
|
||||
setErrorMsg(null);
|
||||
try {
|
||||
const res = await runSingleOp(selectedOp.id, activeSample);
|
||||
setRunResult({ ...res, opId: selectedOp.id });
|
||||
setPhase("done");
|
||||
} catch (err) {
|
||||
setPhase("error");
|
||||
setErrorMsg(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setPhase("idle");
|
||||
setRunResult(null);
|
||||
setErrorMsg(null);
|
||||
}
|
||||
|
||||
function buildPipelineWithOp() {
|
||||
onClose();
|
||||
setActiveView("pipelines");
|
||||
}
|
||||
|
||||
function onDragOver(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
}
|
||||
function onDragLeave() {
|
||||
setDragOver(false);
|
||||
}
|
||||
function onDrop(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) setSample(file.name);
|
||||
else pickSample();
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width="xl"
|
||||
title={t("portal.opRunner.title")}
|
||||
subtitle={t("portal.opRunner.subtitle")}
|
||||
footer={
|
||||
<>
|
||||
<div className="portal-runner__footer-status">
|
||||
{phase === "running" && selectedOp && (
|
||||
<>
|
||||
<span className="portal-runner__spinner" aria-hidden />
|
||||
<code>POST {selectedOp.endpoint}</code>
|
||||
</>
|
||||
)}
|
||||
{phase === "done" && (
|
||||
<StatusBadge tone="success" size="sm">
|
||||
200 OK
|
||||
</StatusBadge>
|
||||
)}
|
||||
{phase === "error" && (
|
||||
<StatusBadge tone="danger" size="sm">
|
||||
{errorMsg ?? t("portal.opRunner.status.failed")}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="tertiary" onClick={onClose}>
|
||||
{t("portal.opRunner.action.close")}
|
||||
</Button>
|
||||
{phase === "done" ? (
|
||||
<>
|
||||
<Button variant="secondary" onClick={reset}>
|
||||
{t("portal.opRunner.action.runAgain")}
|
||||
</Button>
|
||||
<Button
|
||||
accent="premium"
|
||||
onClick={buildPipelineWithOp}
|
||||
rightSection={<span aria-hidden>→</span>}
|
||||
>
|
||||
{t("portal.opRunner.action.openBuilder")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
accent="premium"
|
||||
onClick={run}
|
||||
disabled={phase === "running" || !selectedOp}
|
||||
rightSection={<span aria-hidden>→</span>}
|
||||
>
|
||||
{phase === "running"
|
||||
? t("portal.opRunner.action.running")
|
||||
: t("portal.opRunner.action.run")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="portal-runner__layout">
|
||||
{/* Left column: file + op picker */}
|
||||
<section className="portal-runner__left">
|
||||
<div
|
||||
className={
|
||||
"portal-runner__drop" +
|
||||
(dragOver ? " is-armed" : "") +
|
||||
(sample ? " has-file" : "")
|
||||
}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<div className="portal-runner__drop-icon" aria-hidden>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="32"
|
||||
height="32"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="portal-runner__drop-text">
|
||||
{sample ? (
|
||||
<>
|
||||
<strong>{sample}</strong>
|
||||
<span>{t("portal.opRunner.drop.replaceHint")}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong>{t("portal.opRunner.drop.title")}</strong>
|
||||
<span>{t("portal.opRunner.drop.hint")}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="quiet"
|
||||
className="portal-runner__sample-btn"
|
||||
onClick={pickSample}
|
||||
>
|
||||
{sample
|
||||
? t("portal.opRunner.drop.pickAnother")
|
||||
: t("portal.opRunner.drop.useSample")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="portal-runner__section-title">
|
||||
{t("portal.opRunner.featuredOps")}
|
||||
</div>
|
||||
<div className="portal-runner__ops">
|
||||
{opsIsLoading &&
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<div
|
||||
key={`op-skel-${i}`}
|
||||
className="portal-runner__op portal-runner__op--skeleton"
|
||||
aria-hidden
|
||||
>
|
||||
<Skeleton width="60%" />
|
||||
<Skeleton width="40%" height="0.625rem" />
|
||||
<Skeleton width="80%" height="0.625rem" />
|
||||
</div>
|
||||
))}
|
||||
{opsIsEmpty && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={t("portal.opRunner.empty.title")}
|
||||
description={t("portal.opRunner.empty.description")}
|
||||
/>
|
||||
)}
|
||||
{ops?.map((op) => (
|
||||
<Button
|
||||
key={op.id}
|
||||
type="button"
|
||||
variant="quiet"
|
||||
className={
|
||||
"portal-runner__op" +
|
||||
(selectedOpId === op.id ? " is-selected" : "")
|
||||
}
|
||||
onClick={() => {
|
||||
setSelectedOpId(op.id);
|
||||
if (phase === "done") {
|
||||
setPhase("idle");
|
||||
setRunResult(null);
|
||||
}
|
||||
}}
|
||||
data-accent={op.accent}
|
||||
>
|
||||
<span className="portal-runner__op-label">{op.label}</span>
|
||||
<span className="portal-runner__op-endpoint">
|
||||
{op.endpoint}
|
||||
</span>
|
||||
<span className="portal-runner__op-blurb">{op.blurb}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Right column: state-driven result panel */}
|
||||
<section className="portal-runner__right">
|
||||
{phase === "idle" && selectedOp && (
|
||||
<div className="portal-runner__hint">
|
||||
<div className="portal-runner__hint-eyebrow">
|
||||
{t("portal.opRunner.hint.ready")}
|
||||
</div>
|
||||
<h3>
|
||||
{t("portal.opRunner.hint.runOn.before")}{" "}
|
||||
<code>{selectedOp.label}</code>{" "}
|
||||
{t("portal.opRunner.hint.runOn.middle")}{" "}
|
||||
<code>{sample ?? t("portal.opRunner.hint.aSample")}</code>
|
||||
</h3>
|
||||
<p>
|
||||
{selectedOp.blurb}. {t("portal.opRunner.hint.press")}{" "}
|
||||
<kbd>{t("portal.opRunner.action.run")}</kbd>{" "}
|
||||
{t("portal.opRunner.hint.toInvoke")}{" "}
|
||||
<code>POST {selectedOp.endpoint}</code>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{phase === "running" && selectedOp && (
|
||||
<div className="portal-runner__running">
|
||||
<div className="portal-runner__spinner-lg" aria-hidden />
|
||||
<div className="portal-runner__running-text">
|
||||
<div className="portal-runner__running-title">
|
||||
{t("portal.opRunner.running.title", {
|
||||
label: selectedOp.label,
|
||||
})}
|
||||
</div>
|
||||
<code>POST {selectedOp.endpoint}</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{phase === "done" && selectedOp && runResult && (
|
||||
<div className="portal-runner__result">
|
||||
<header className="portal-runner__result-head">
|
||||
<StatusBadge tone="success" size="sm">
|
||||
{t("portal.opRunner.status.completed")}
|
||||
</StatusBadge>
|
||||
<code>POST {selectedOp.endpoint}</code>
|
||||
<span className="portal-runner__result-meta">
|
||||
{t("portal.opRunner.durationMs", {
|
||||
ms: runResult.durationMs,
|
||||
})}
|
||||
</span>
|
||||
</header>
|
||||
<pre className="portal-runner__result-code">
|
||||
{JSON.stringify(runResult.result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{phase === "error" && (
|
||||
<div className="portal-runner__hint">
|
||||
<div
|
||||
className="portal-runner__hint-eyebrow"
|
||||
style={{ color: "var(--color-red)" }}
|
||||
>
|
||||
{t("portal.opRunner.status.failed")}
|
||||
</div>
|
||||
<h3>{t("portal.opRunner.error.title")}</h3>
|
||||
<p>{errorMsg ?? t("portal.opRunner.error.unknown")}</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
.portal-chart {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1.125rem 1.25rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.portal-chart__head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.portal-chart__label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-section-label);
|
||||
}
|
||||
|
||||
.portal-chart__value {
|
||||
margin-top: 0.125rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.portal-chart__delta {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.portal-chart__delta.is-up {
|
||||
color: var(--color-green-dark);
|
||||
background: var(--color-green-light);
|
||||
}
|
||||
|
||||
.portal-chart__delta.is-down {
|
||||
color: var(--color-red);
|
||||
background: var(--color-red-light);
|
||||
}
|
||||
|
||||
.portal-chart__svg {
|
||||
width: 100%;
|
||||
height: 15rem;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.portal-chart__gridline {
|
||||
stroke: var(--color-border-light);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 3 3;
|
||||
}
|
||||
|
||||
.portal-chart__axis-label {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 10.5px;
|
||||
fill: var(--color-text-5);
|
||||
}
|
||||
|
||||
.portal-chart__line {
|
||||
fill: none;
|
||||
stroke: var(--color-blue);
|
||||
stroke-width: 1.75;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.portal-chart__scrub {
|
||||
stroke: var(--color-text-5);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 2 3;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.portal-chart__dot {
|
||||
fill: var(--color-blue);
|
||||
stroke: var(--color-surface);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.portal-chart__tooltip {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
top: 4rem;
|
||||
padding: 0.4375rem 0.625rem;
|
||||
background: var(--color-tooltip-bg);
|
||||
color: var(--color-tooltip-text);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.portal-chart__tooltip-date {
|
||||
font-size: 0.6875rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.portal-chart__tooltip-value {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { UsageAreaChart } from "@portal/components/UsageAreaChart";
|
||||
import { buildUsageSeries } from "@portal/mocks/home";
|
||||
|
||||
const meta: Meta<typeof UsageAreaChart> = {
|
||||
title: "Portal/Home/UsageAreaChart",
|
||||
component: UsageAreaChart,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "60rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof UsageAreaChart>;
|
||||
|
||||
const series = buildUsageSeries();
|
||||
const total = series.reduce((sum, p) => sum + p.value, 0);
|
||||
|
||||
export const Default: Story = {
|
||||
args: { data: series, totalValue: total.toLocaleString(), deltaPct: 0.12 },
|
||||
};
|
||||
|
||||
export const NegativeDelta: Story = {
|
||||
args: { data: series, totalValue: total.toLocaleString(), deltaPct: -0.08 },
|
||||
};
|
||||
|
||||
export const NoDelta: Story = {
|
||||
args: { data: series },
|
||||
};
|
||||
|
||||
export const Flat: Story = {
|
||||
args: {
|
||||
data: series.map((p) => ({ ...p, value: 1200 })),
|
||||
totalValue: "36,000",
|
||||
},
|
||||
};
|
||||
|
||||
export const HighVolatility: Story = {
|
||||
args: {
|
||||
data: series.map((p, i) => ({
|
||||
...p,
|
||||
value: Math.round(p.value * (1 + Math.sin(i) * 0.6)),
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: { data: [], totalValue: "—" },
|
||||
};
|
||||
@@ -1,288 +0,0 @@
|
||||
import { useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { UsagePoint } from "@portal/api/home";
|
||||
import "@portal/components/UsageAreaChart.css";
|
||||
|
||||
interface UsageAreaChartProps {
|
||||
data: UsagePoint[];
|
||||
/** Headline metric shown above the chart (e.g. total 30d, current value). */
|
||||
totalLabel?: string;
|
||||
totalValue?: string;
|
||||
deltaPct?: number;
|
||||
}
|
||||
|
||||
const PADDING = { top: 16, right: 20, bottom: 28, left: 32 } as const;
|
||||
const VIEW = { width: 800, height: 240 } as const;
|
||||
|
||||
function formatTick(date: string): string {
|
||||
return new Date(date).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
if (value >= 1000) {
|
||||
return `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`;
|
||||
}
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
export function UsageAreaChart({
|
||||
data,
|
||||
totalLabel: totalLabelProp,
|
||||
totalValue,
|
||||
deltaPct,
|
||||
}: UsageAreaChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const totalLabel = totalLabelProp ?? t("portal.usageChart.defaultLabel");
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
|
||||
const { points, areaPath, linePath, yMax, yTicks, xTickIndices } =
|
||||
useMemo(() => {
|
||||
if (data.length === 0) {
|
||||
return {
|
||||
points: [] as Array<{ x: number; y: number; raw: UsagePoint }>,
|
||||
areaPath: "",
|
||||
linePath: "",
|
||||
yMax: 0,
|
||||
yTicks: [] as number[],
|
||||
xTickIndices: [] as number[],
|
||||
};
|
||||
}
|
||||
const max = Math.max(...data.map((d) => d.value));
|
||||
// Round yMax up to a "nice" number for ticks. Floor at 500 so an
|
||||
// all-zero series doesn't divide by zero (which would NaN every point).
|
||||
const niceMax = Math.max(Math.ceil(max / 500) * 500, 500);
|
||||
const yTicksCalc = [0, niceMax * 0.5, niceMax];
|
||||
|
||||
const innerW = VIEW.width - PADDING.left - PADDING.right;
|
||||
const innerH = VIEW.height - PADDING.top - PADDING.bottom;
|
||||
const xStep = innerW / Math.max(data.length - 1, 1);
|
||||
|
||||
const pts = data.map((raw, i) => ({
|
||||
x: PADDING.left + i * xStep,
|
||||
y: PADDING.top + innerH - (raw.value / niceMax) * innerH,
|
||||
raw,
|
||||
}));
|
||||
|
||||
const linePathStr = pts
|
||||
.map(
|
||||
(p, i) =>
|
||||
`${i === 0 ? "M" : "L"} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`,
|
||||
)
|
||||
.join(" ");
|
||||
const baseY = PADDING.top + innerH;
|
||||
const areaPathStr = `${linePathStr} L ${pts[pts.length - 1].x.toFixed(2)} ${baseY} L ${pts[0].x.toFixed(2)} ${baseY} Z`;
|
||||
|
||||
// X ticks: 5 evenly spread.
|
||||
const xIdx: number[] = [];
|
||||
const tickCount = 5;
|
||||
for (let t = 0; t < tickCount; t++) {
|
||||
xIdx.push(Math.round((t * (data.length - 1)) / (tickCount - 1)));
|
||||
}
|
||||
|
||||
return {
|
||||
points: pts,
|
||||
areaPath: areaPathStr,
|
||||
linePath: linePathStr,
|
||||
yMax: niceMax,
|
||||
yTicks: yTicksCalc,
|
||||
xTickIndices: xIdx,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
function onMouseMove(e: React.MouseEvent<SVGSVGElement>) {
|
||||
if (!svgRef.current || points.length === 0) return;
|
||||
const rect = svgRef.current.getBoundingClientRect();
|
||||
const xRatio = (e.clientX - rect.left) / rect.width;
|
||||
const svgX = xRatio * VIEW.width;
|
||||
// Find nearest point
|
||||
let nearestIdx = 0;
|
||||
let nearestDist = Infinity;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const d = Math.abs(points[i].x - svgX);
|
||||
if (d < nearestDist) {
|
||||
nearestDist = d;
|
||||
nearestIdx = i;
|
||||
}
|
||||
}
|
||||
setHoverIndex(nearestIdx);
|
||||
}
|
||||
|
||||
const hovered = hoverIndex !== null ? points[hoverIndex] : null;
|
||||
|
||||
function onKeyDown(e: KeyboardEvent<SVGSVGElement>) {
|
||||
if (points.length === 0) return;
|
||||
if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
setHoverIndex((idx) =>
|
||||
idx === null ? 0 : Math.min(points.length - 1, idx + 1),
|
||||
);
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
setHoverIndex((idx) =>
|
||||
idx === null ? points.length - 1 : Math.max(0, idx - 1),
|
||||
);
|
||||
} else if (e.key === "Home") {
|
||||
e.preventDefault();
|
||||
setHoverIndex(0);
|
||||
} else if (e.key === "End") {
|
||||
e.preventDefault();
|
||||
setHoverIndex(points.length - 1);
|
||||
} else if (e.key === "Escape") {
|
||||
setHoverIndex(null);
|
||||
}
|
||||
}
|
||||
|
||||
const displayTotal =
|
||||
totalValue ?? data.reduce((sum, p) => sum + p.value, 0).toLocaleString();
|
||||
|
||||
return (
|
||||
<div className="portal-chart">
|
||||
<header className="portal-chart__head">
|
||||
<div>
|
||||
<div className="portal-chart__label">{totalLabel}</div>
|
||||
<div className="portal-chart__value">{displayTotal}</div>
|
||||
</div>
|
||||
{deltaPct !== undefined && (
|
||||
<div
|
||||
className={
|
||||
"portal-chart__delta " + (deltaPct >= 0 ? "is-up" : "is-down")
|
||||
}
|
||||
>
|
||||
<span aria-hidden>{deltaPct >= 0 ? "↑" : "↓"}</span>
|
||||
{t("portal.usageChart.delta", {
|
||||
pct: Math.abs(Math.round(deltaPct * 100)),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${VIEW.width} ${VIEW.height}`}
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label={totalLabel}
|
||||
className="portal-chart__svg"
|
||||
tabIndex={points.length > 0 ? 0 : -1}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={() => setHoverIndex(null)}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={() => setHoverIndex(null)}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="chart-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="var(--color-blue)"
|
||||
stopOpacity="0.32"
|
||||
/>
|
||||
<stop offset="100%" stopColor="var(--color-blue)" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Y gridlines + labels */}
|
||||
{yTicks.map((tick) => {
|
||||
const innerH = VIEW.height - PADDING.top - PADDING.bottom;
|
||||
const y = PADDING.top + innerH - (tick / yMax) * innerH;
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line
|
||||
x1={PADDING.left}
|
||||
x2={VIEW.width - PADDING.right}
|
||||
y1={y}
|
||||
y2={y}
|
||||
className="portal-chart__gridline"
|
||||
/>
|
||||
<text
|
||||
x={PADDING.left - 6}
|
||||
y={y + 4}
|
||||
className="portal-chart__axis-label"
|
||||
textAnchor="end"
|
||||
>
|
||||
{formatNumber(tick)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Area + line */}
|
||||
<path d={areaPath} fill="url(#chart-fill)" />
|
||||
<path d={linePath} className="portal-chart__line" />
|
||||
|
||||
{/* X ticks */}
|
||||
{xTickIndices.map((idx) => {
|
||||
const p = points[idx];
|
||||
if (!p) return null;
|
||||
return (
|
||||
<text
|
||||
key={idx}
|
||||
x={p.x}
|
||||
y={VIEW.height - PADDING.bottom + 18}
|
||||
className="portal-chart__axis-label"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{formatTick(p.raw.date)}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Hover scrub + dot + tooltip */}
|
||||
{hovered && (
|
||||
<>
|
||||
<line
|
||||
x1={hovered.x}
|
||||
x2={hovered.x}
|
||||
y1={PADDING.top}
|
||||
y2={VIEW.height - PADDING.bottom}
|
||||
className="portal-chart__scrub"
|
||||
/>
|
||||
<circle
|
||||
cx={hovered.x}
|
||||
cy={hovered.y}
|
||||
r={4}
|
||||
className="portal-chart__dot"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{hovered && (
|
||||
<div
|
||||
className="portal-chart__tooltip"
|
||||
style={{
|
||||
left: `${(hovered.x / VIEW.width) * 100}%`,
|
||||
}}
|
||||
>
|
||||
<div className="portal-chart__tooltip-date">
|
||||
{new Date(hovered.raw.date).toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</div>
|
||||
<div className="portal-chart__tooltip-value">
|
||||
{t("portal.usageChart.docsValue", {
|
||||
value: hovered.raw.value.toLocaleString(),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="sr-only" aria-live="polite" aria-atomic="true">
|
||||
{hovered
|
||||
? t("portal.usageChart.srAnnounce", {
|
||||
date: new Date(hovered.raw.date).toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
value: hovered.raw.value.toLocaleString(),
|
||||
})
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import {
|
||||
buildUsageSeries,
|
||||
buildUsageSeriesResponse,
|
||||
enterpriseKpisFor,
|
||||
FREE_KPIS,
|
||||
proKpisFor,
|
||||
RECENT_ACTIVITY,
|
||||
REGION_HEALTH,
|
||||
type KpiEntry,
|
||||
} from "@portal/mocks/home";
|
||||
|
||||
function kpisFor(tier: Tier): KpiEntry[] {
|
||||
if (tier === "free") return FREE_KPIS;
|
||||
const docs30d = buildUsageSeries().reduce((sum, p) => sum + p.value, 0);
|
||||
if (tier === "enterprise") return enterpriseKpisFor(docs30d);
|
||||
return proKpisFor(docs30d);
|
||||
}
|
||||
|
||||
export const homeHandlers = [
|
||||
http.get("/v1/analytics/usage", async () => {
|
||||
await delay(120);
|
||||
return HttpResponse.json(buildUsageSeriesResponse());
|
||||
}),
|
||||
|
||||
http.get("/v1/activity", async () => {
|
||||
await delay(120);
|
||||
return HttpResponse.json(RECENT_ACTIVITY);
|
||||
}),
|
||||
|
||||
http.get("/v1/home/kpis", async ({ request }) => {
|
||||
await delay(120);
|
||||
const url = new URL(request.url);
|
||||
const tier = (url.searchParams.get("tier") ?? "pro") as Tier;
|
||||
return HttpResponse.json(kpisFor(tier));
|
||||
}),
|
||||
|
||||
http.get("/v1/regions/health", async () => {
|
||||
await delay(120);
|
||||
return HttpResponse.json(REGION_HEALTH);
|
||||
}),
|
||||
];
|
||||
@@ -1,8 +1,6 @@
|
||||
import { assistantHandlers } from "@portal/mocks/handlers/assistant";
|
||||
import { authHandlers } from "@portal/mocks/handlers/auth";
|
||||
import { homeHandlers } from "@portal/mocks/handlers/home";
|
||||
import { notificationsHandlers } from "@portal/mocks/handlers/notifications";
|
||||
import { opsHandlers } from "@portal/mocks/handlers/ops";
|
||||
import { searchHandlers } from "@portal/mocks/handlers/search";
|
||||
import { pipelinesHandlers } from "@portal/mocks/handlers/pipelines";
|
||||
import { sourcesHandlers } from "@portal/mocks/handlers/sources";
|
||||
@@ -21,8 +19,6 @@ import { linkHandlers } from "@portal/mocks/handlers/link";
|
||||
|
||||
export const handlers = [
|
||||
...authHandlers,
|
||||
...homeHandlers,
|
||||
...opsHandlers,
|
||||
...notificationsHandlers,
|
||||
...assistantHandlers,
|
||||
...searchHandlers,
|
||||
@@ -53,8 +49,6 @@ export const handlers = [
|
||||
* Everything kept is portal-only. `handlers` above is still the full set.
|
||||
*/
|
||||
export const embeddedDataHandlers = [
|
||||
...homeHandlers,
|
||||
...opsHandlers,
|
||||
...notificationsHandlers,
|
||||
...assistantHandlers,
|
||||
...searchHandlers,
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import { FEATURED_OPS, OP_RESULTS } from "@portal/mocks/ops";
|
||||
|
||||
export const opsHandlers = [
|
||||
http.get("/v1/ops/featured", async () => {
|
||||
await delay(120);
|
||||
return HttpResponse.json(FEATURED_OPS);
|
||||
}),
|
||||
|
||||
http.post("/v1/ops/:opId/run", async ({ params }) => {
|
||||
const start = performance.now();
|
||||
await delay(800 + Math.random() * 200);
|
||||
const opId = String(params.opId);
|
||||
const result = OP_RESULTS[opId];
|
||||
if (!result) {
|
||||
return HttpResponse.json(
|
||||
{ error: `Unknown op: ${opId}` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const enriched =
|
||||
opId === "sign-output"
|
||||
? { ...result, signed_at: new Date().toISOString() }
|
||||
: result;
|
||||
return HttpResponse.json({
|
||||
result: enriched,
|
||||
durationMs: Math.round(performance.now() - start),
|
||||
});
|
||||
}),
|
||||
];
|
||||
@@ -1,285 +0,0 @@
|
||||
/**
|
||||
* Home dashboard fixtures and the types api/home.ts shares with them.
|
||||
* api/home.ts imports the types; the MSW handlers in mocks/handlers/ serve the
|
||||
* fixture data over the intercepted apiClient.local.json() calls. Components never reach
|
||||
* into this module directly.
|
||||
*
|
||||
* Once a real backend exists, the MSW handlers stop being registered and these
|
||||
* fixtures can be deleted (or kept as test seeds).
|
||||
*/
|
||||
|
||||
export interface UsagePoint {
|
||||
/** ISO date (YYYY-MM-DD). */
|
||||
date: string;
|
||||
/** Docs processed on that day. */
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server response for the usage-series endpoint. Returning the prior window's
|
||||
* total alongside the points lets the client derive the headline delta
|
||||
* deterministically from real data rather than carrying a hardcoded figure.
|
||||
*/
|
||||
export interface UsageSeriesResponse {
|
||||
points: UsagePoint[];
|
||||
/** Equivalent docs total from the immediately prior 30-day window. */
|
||||
priorTotal: number;
|
||||
}
|
||||
|
||||
/** Builds 30 daily points ending today. Deterministic per day. */
|
||||
export function buildUsageSeries(): UsagePoint[] {
|
||||
const points: UsagePoint[] = [];
|
||||
const now = new Date();
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const d = new Date(now);
|
||||
d.setDate(now.getDate() - i);
|
||||
const day = d.getDay();
|
||||
// Weekend dip + slow uptrend + bounded noise.
|
||||
const weekend = day === 0 || day === 6 ? 0.55 : 1;
|
||||
const trend = 1 + (30 - i) * 0.012;
|
||||
const wobble = 1 + Math.sin(i * 1.3) * 0.18 + Math.cos(i * 0.6) * 0.09;
|
||||
const base = 1450 * weekend * trend * wobble;
|
||||
points.push({
|
||||
date: d.toISOString().slice(0, 10),
|
||||
value: Math.round(base),
|
||||
});
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/** Builds the full usage payload with a plausible prior-window total. */
|
||||
export function buildUsageSeriesResponse(): UsageSeriesResponse {
|
||||
const points = buildUsageSeries();
|
||||
const currentTotal = points.reduce((sum, p) => sum + p.value, 0);
|
||||
// The current window's series simulates ~12% growth over the prior one.
|
||||
const priorTotal = Math.round(currentTotal / 1.12);
|
||||
return { points, priorTotal };
|
||||
}
|
||||
|
||||
export type ActivityKind =
|
||||
| "pipeline-run"
|
||||
| "deploy"
|
||||
| "drift"
|
||||
| "eval"
|
||||
| "agent"
|
||||
| "billing";
|
||||
|
||||
export interface ActivityEvent {
|
||||
id: string;
|
||||
kind: ActivityKind;
|
||||
/** Short action verb shown at the top of the row. */
|
||||
action: string;
|
||||
/** Subject of the action (pipeline / endpoint / agent name). */
|
||||
subject: string;
|
||||
/** One-line detail line under the action. */
|
||||
detail: string;
|
||||
/** Relative-time string. */
|
||||
time: string;
|
||||
status: "success" | "warning" | "danger" | "info";
|
||||
}
|
||||
|
||||
export const RECENT_ACTIVITY: ActivityEvent[] = [
|
||||
{
|
||||
id: "act-1",
|
||||
kind: "pipeline-run",
|
||||
action: "Pipeline run completed",
|
||||
subject: "COI Compliance",
|
||||
detail: "1,287 docs · 0.4% errors · P95 412 ms",
|
||||
time: "2m ago",
|
||||
status: "success",
|
||||
},
|
||||
{
|
||||
id: "act-2",
|
||||
kind: "deploy",
|
||||
action: "Deployed",
|
||||
subject: "Prior Auth v3.1.0",
|
||||
detail: "Promoted to us-east-1, eu-west-1 · golden set 36/36",
|
||||
time: "14m ago",
|
||||
status: "success",
|
||||
},
|
||||
{
|
||||
id: "act-3",
|
||||
kind: "drift",
|
||||
action: "Schema drift detected",
|
||||
subject: "Invoice v3",
|
||||
detail: "12 docs in 1h didn't match — confidence ↓ 0.07",
|
||||
time: "1h ago",
|
||||
status: "warning",
|
||||
},
|
||||
{
|
||||
id: "act-4",
|
||||
kind: "eval",
|
||||
action: "Eval set passed",
|
||||
subject: "KYC Processor",
|
||||
detail: "94% (26/28) — 2 cases sent to review",
|
||||
time: "3h ago",
|
||||
status: "success",
|
||||
},
|
||||
{
|
||||
id: "act-5",
|
||||
kind: "agent",
|
||||
action: "Agent escalated",
|
||||
subject: "Contract Router",
|
||||
detail: "Low-confidence DPA routed to L2 reviewer pool",
|
||||
time: "5h ago",
|
||||
status: "info",
|
||||
},
|
||||
{
|
||||
id: "act-6",
|
||||
kind: "pipeline-run",
|
||||
action: "Pipeline run failed",
|
||||
subject: "Contract Review",
|
||||
detail: "8% error rate · 14 docs sent to review queue",
|
||||
time: "8h ago",
|
||||
status: "danger",
|
||||
},
|
||||
{
|
||||
id: "act-7",
|
||||
kind: "billing",
|
||||
action: "Approaching cap",
|
||||
subject: "Monthly usage",
|
||||
detail: "389k of 500k docs · auto-upgrade disabled",
|
||||
time: "yesterday",
|
||||
status: "warning",
|
||||
},
|
||||
{
|
||||
id: "act-8",
|
||||
kind: "deploy",
|
||||
action: "Rolled back",
|
||||
subject: "COI Compliance v2.3.7",
|
||||
detail: "Confidence regressions on Carrier supplement",
|
||||
time: "2d ago",
|
||||
status: "warning",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* KPI labels are owned by the client (see `KPI_LABELS_BY_TIER` in Home.tsx)
|
||||
* because they're product copy that should stay stable across loading / empty
|
||||
* / ready states. The API only ships values + deltas.
|
||||
*/
|
||||
export interface KpiEntry {
|
||||
value: string | number;
|
||||
delta?: number;
|
||||
description?: string;
|
||||
deltaDirection?: "up" | "down" | "flat";
|
||||
}
|
||||
|
||||
export const FREE_KPIS: KpiEntry[] = [
|
||||
{ value: "247 / 500" },
|
||||
{ value: 189 },
|
||||
{ value: 3 },
|
||||
{ value: 1 },
|
||||
];
|
||||
|
||||
export function proKpisFor(docs30d: number): KpiEntry[] {
|
||||
return [
|
||||
{ value: docs30d.toLocaleString(), delta: 0.12 },
|
||||
{ value: 12, delta: 0.16 },
|
||||
{ value: 7, delta: 0.4 },
|
||||
{ value: "94.6%", delta: 0.02 },
|
||||
];
|
||||
}
|
||||
|
||||
export function enterpriseKpisFor(docs30d: number): KpiEntry[] {
|
||||
return [
|
||||
{ value: docs30d.toLocaleString(), delta: 0.18 },
|
||||
{ value: "412 ms", delta: -0.05 },
|
||||
{ value: "96.2%", delta: 0.01 },
|
||||
{ value: "99.987%" },
|
||||
];
|
||||
}
|
||||
|
||||
export interface RegionHealth {
|
||||
name: string;
|
||||
status: "healthy" | "degraded" | "down";
|
||||
meta: string;
|
||||
}
|
||||
|
||||
export const REGION_HEALTH: RegionHealth[] = [
|
||||
{
|
||||
name: "us-east-1",
|
||||
status: "healthy",
|
||||
meta: "2.1k/min · P95 287ms · 99.99% uptime",
|
||||
},
|
||||
{
|
||||
name: "eu-west-1",
|
||||
status: "healthy",
|
||||
meta: "1.4k/min · P95 312ms · 99.98% uptime",
|
||||
},
|
||||
{
|
||||
name: "ap-southeast-1",
|
||||
status: "degraded",
|
||||
meta: "412/min · P95 521ms · 99.92% uptime · degraded",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Starter pipelines offered by the Home fork wizard. Forking clones one of
|
||||
* these templates as the seed for a new developer pipeline. Every template
|
||||
* runs the same four canonical stages (Ingest → Validate → Secure → Store);
|
||||
* the fixtures differ only in framing copy and which document types they target.
|
||||
*/
|
||||
export interface PipelineTemplate {
|
||||
id: string;
|
||||
/** Display name shown on the template chip and the ready-state header. */
|
||||
name: string;
|
||||
/** One-line description of what the forked pipeline does. */
|
||||
blurb: string;
|
||||
/** Document types this template is tuned for. */
|
||||
docTypes: string[];
|
||||
accent: "blue" | "purple" | "green" | "amber";
|
||||
}
|
||||
|
||||
export const PIPELINE_TEMPLATES: PipelineTemplate[] = [
|
||||
{
|
||||
id: "coi-compliance",
|
||||
name: "COI Compliance",
|
||||
blurb: "Validate certificates of insurance against carrier requirements.",
|
||||
docTypes: ["Certificates of insurance", "Loss runs"],
|
||||
accent: "blue",
|
||||
},
|
||||
{
|
||||
id: "accounts-payable",
|
||||
name: "Accounts Payable",
|
||||
blurb: "Extract line items, match POs, and flag duplicate invoices.",
|
||||
docTypes: ["Invoices", "Purchase orders"],
|
||||
accent: "green",
|
||||
},
|
||||
{
|
||||
id: "contract-review",
|
||||
name: "Contract Review",
|
||||
blurb: "Classify clauses, redact PII, and route to the right reviewer.",
|
||||
docTypes: ["MSAs", "DPAs", "NDAs"],
|
||||
accent: "purple",
|
||||
},
|
||||
{
|
||||
id: "prior-authorization",
|
||||
name: "Prior Authorization",
|
||||
blurb: "Read auth requests, check coverage, and assemble payer packets.",
|
||||
docTypes: ["Auth requests", "Clinical notes"],
|
||||
accent: "amber",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The four canonical stages every forked pipeline runs. Fixed and ordered —
|
||||
* the wizard's build animation lights them up left-to-right.
|
||||
*/
|
||||
export interface PipelineStage {
|
||||
key: string;
|
||||
label: string;
|
||||
/** What the stage does, shown under the label in the ready-state grid. */
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export const PIPELINE_STAGES: PipelineStage[] = [
|
||||
{ key: "ingest", label: "Ingest", detail: "Accept, normalize, deduplicate" },
|
||||
{ key: "validate", label: "Validate", detail: "Classify and check schema" },
|
||||
{ key: "secure", label: "Secure", detail: "Redact PII, encrypt at rest" },
|
||||
{ key: "store", label: "Store", detail: "Emit JSON, persist, notify" },
|
||||
];
|
||||
|
||||
// The free/subscribed "Finish setting up" checklist is no longer fixture-driven:
|
||||
// SetupChecklist builds its steps client-side and derives completion from the
|
||||
// live policies + sources APIs. See components/SetupChecklist.tsx.
|
||||
@@ -1,166 +0,0 @@
|
||||
/**
|
||||
* Mock op catalogue + canned operation results for the single-op runner.
|
||||
* Only api/ops.ts imports from this file.
|
||||
*/
|
||||
|
||||
export type OpResultMap = Record<string, unknown>;
|
||||
|
||||
export interface FeaturedOp {
|
||||
id: string;
|
||||
label: string;
|
||||
endpoint: string;
|
||||
accent: "blue" | "purple" | "green" | "amber" | "red";
|
||||
/** Shown in the picker — short single line. */
|
||||
blurb: string;
|
||||
}
|
||||
|
||||
export const FEATURED_OPS: FeaturedOp[] = [
|
||||
{
|
||||
id: "extract",
|
||||
label: "Extract",
|
||||
endpoint: "/v1/extract",
|
||||
accent: "blue",
|
||||
blurb: "Pull structured fields into a typed schema",
|
||||
},
|
||||
{
|
||||
id: "redact",
|
||||
label: "Redact PII",
|
||||
endpoint: "/v1/redact",
|
||||
accent: "red",
|
||||
blurb: "Mask SSN, DOB, addresses, accounts before storage",
|
||||
},
|
||||
{
|
||||
id: "classify",
|
||||
label: "Classify",
|
||||
endpoint: "/v1/classify",
|
||||
accent: "purple",
|
||||
blurb: "Identify document type with a confidence score",
|
||||
},
|
||||
{
|
||||
id: "ocr",
|
||||
label: "OCR",
|
||||
endpoint: "/v1/ocr",
|
||||
accent: "green",
|
||||
blurb: "Text-recognize scanned or image pages",
|
||||
},
|
||||
{
|
||||
id: "validate",
|
||||
label: "Schema validate",
|
||||
endpoint: "/v1/validate",
|
||||
accent: "blue",
|
||||
blurb: "Check fields, rules, and coverage against the schema",
|
||||
},
|
||||
{
|
||||
id: "sign-output",
|
||||
label: "Sign output",
|
||||
endpoint: "/v1/sign",
|
||||
accent: "green",
|
||||
blurb: "Tamper-evident signature over artifact + run metadata",
|
||||
},
|
||||
{
|
||||
id: "authenticity",
|
||||
label: "Authenticity",
|
||||
endpoint: "/v1/authenticity",
|
||||
accent: "blue",
|
||||
blurb: "Verify issuer signature, watermark, and metadata",
|
||||
},
|
||||
{
|
||||
id: "tamper-check",
|
||||
label: "Tamper check",
|
||||
endpoint: "/v1/tamper-check",
|
||||
accent: "amber",
|
||||
blurb: "Detect modifications since signing or last-known-good state",
|
||||
},
|
||||
{
|
||||
id: "encrypt-rest",
|
||||
label: "Encrypt at rest",
|
||||
endpoint: "/v1/encrypt",
|
||||
accent: "purple",
|
||||
blurb: "AES-256 with Stirling-managed, BYOK, or HYOK keys",
|
||||
},
|
||||
{
|
||||
id: "smart-redact",
|
||||
label: "Smart redact",
|
||||
endpoint: "/v1/smart-redact",
|
||||
accent: "red",
|
||||
blurb: "Schema-aware redaction with confidence gating",
|
||||
},
|
||||
];
|
||||
|
||||
/** Canned JSON for each featured op's runner "done" state. */
|
||||
export const OP_RESULTS: Record<string, OpResultMap> = {
|
||||
extract: {
|
||||
schema: "coi.v2",
|
||||
fields: {
|
||||
carrier: "Travelers Casualty",
|
||||
policy_number: "PHB-1108-2025",
|
||||
gl_limit: 1_000_000,
|
||||
umbrella_limit: 5_000_000,
|
||||
effective: "2026-01-15",
|
||||
expiry: "2027-01-15",
|
||||
},
|
||||
confidence_avg: 0.96,
|
||||
},
|
||||
redact: {
|
||||
redacted_pages: 4,
|
||||
pii_types: ["SSN", "DOB", "ADDRESS", "EMAIL"],
|
||||
occurrences: 19,
|
||||
redaction_style: "blackout",
|
||||
audit_id: "rdct_01HVQ7K3ZA9YJ8C",
|
||||
},
|
||||
classify: {
|
||||
schema: "invoice.v3",
|
||||
confidence: 0.94,
|
||||
alternatives: [
|
||||
{ schema: "credit_memo.v1", confidence: 0.04 },
|
||||
{ schema: "purchase_order.v2", confidence: 0.02 },
|
||||
],
|
||||
processing_ms: 287,
|
||||
},
|
||||
ocr: {
|
||||
pages: 12,
|
||||
characters_recognized: 28471,
|
||||
confidence_avg: 0.987,
|
||||
languages_detected: ["en"],
|
||||
processing_ms: 1840,
|
||||
},
|
||||
validate: {
|
||||
schema: "coi.v2",
|
||||
passed: true,
|
||||
checks_run: 14,
|
||||
warnings: [
|
||||
{ field: "additional_insured", message: "Optional field empty" },
|
||||
],
|
||||
},
|
||||
"sign-output": {
|
||||
algorithm: "Ed25519",
|
||||
key_id: "kx-prod-2026",
|
||||
manifest_hash:
|
||||
"0xb3f0c1a9d54fa1c0b8fd4eebd7fa11b1b16c9a3e2d2cc6f1f5a2f0a87e1b7a04",
|
||||
},
|
||||
authenticity: {
|
||||
verified: true,
|
||||
issuer: "State of California DMV",
|
||||
signed_at: "2025-11-04T17:22:00Z",
|
||||
watermark_match: true,
|
||||
},
|
||||
"tamper-check": {
|
||||
tampered: false,
|
||||
hash_match: true,
|
||||
modifications_detected: 0,
|
||||
last_known_good: "2026-04-22T09:14:00Z",
|
||||
},
|
||||
"encrypt-rest": {
|
||||
algorithm: "AES-256-GCM",
|
||||
key_mode: "BYOK",
|
||||
key_id: "arn:aws:kms:us-east-1:123:key/abc-…",
|
||||
object_id: "obj_01HVQ7M9B2",
|
||||
},
|
||||
"smart-redact": {
|
||||
schema: "coi.v2",
|
||||
redacted_fields: ["named_insured", "dob"],
|
||||
occurrences: 7,
|
||||
confidence_gate: 0.85,
|
||||
gated_by_confidence: 1,
|
||||
},
|
||||
};
|
||||
@@ -24,43 +24,6 @@
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* Product card grid */
|
||||
.portal-home__product-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
.portal-home__product-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.portal-home__product-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-home__product-title {
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-home__product-blurb {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
/* Metric strip now uses the shared <MetricStrip>. */
|
||||
|
||||
/* Two-column dashboard row (activity + quick actions) */
|
||||
.portal-home__row {
|
||||
display: grid;
|
||||
@@ -154,45 +117,3 @@
|
||||
.portal-home__quick-row:hover .portal-home__quick-arrow {
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
/* Enterprise region strip */
|
||||
.portal-home__regions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
.portal-home__regions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.portal-home__region {
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.portal-home__region-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-home__region-dot {
|
||||
width: 0.4375rem;
|
||||
height: 0.4375rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.portal-home__region-meta {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { Home } from "@portal/views/Home";
|
||||
|
||||
const meta: Meta<typeof Home> = {
|
||||
@@ -39,17 +39,3 @@ export const SubscribedInProcurement: Story = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SlowUsage: Story = {
|
||||
globals: { tier: "pro" },
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/v1/analytics/usage", async () => {
|
||||
await delay(3000);
|
||||
return HttpResponse.json({ points: [], priorTotal: 0 });
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,123 +1,19 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
MetricCard,
|
||||
MetricStrip,
|
||||
Skeleton,
|
||||
StatusBadge,
|
||||
} from "@app/ui";
|
||||
import { useTier, type Tier } from "@portal/contexts/TierContext";
|
||||
import { useView, type ViewId } from "@portal/contexts/ViewContext";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
import {
|
||||
fetchHomeKpis,
|
||||
fetchRegionHealth,
|
||||
fetchUsageSeries,
|
||||
type KpiEntry,
|
||||
type RegionHealth,
|
||||
type UsageSeriesResponse,
|
||||
} from "@portal/api/home";
|
||||
import { Button, Card } from "@app/ui";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
import { HomeHero } from "@portal/components/HomeHero";
|
||||
import { HomeGreeting } from "@portal/components/HomeGreeting";
|
||||
import { PopularUseCases } from "@portal/components/PopularUseCases";
|
||||
import { UsageAreaChart } from "@portal/components/UsageAreaChart";
|
||||
import { RecentActivity } from "@portal/components/RecentActivity";
|
||||
import { SingleOpRunner } from "@portal/components/SingleOpRunner";
|
||||
import { ProcessingStatusStrip } from "@portal/components/ProcessingStatusStrip";
|
||||
import { PolicySummary } from "@portal/components/PolicySummary";
|
||||
import { PipelineForkWizard } from "@portal/components/PipelineForkWizard";
|
||||
import "@portal/views/Home.css";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Product cards (Sources / Pipelines / Agents) */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
interface ProductCardProps {
|
||||
accent: "default" | "premium";
|
||||
badge?: string;
|
||||
title: string;
|
||||
blurb: string;
|
||||
cta: string;
|
||||
target: ViewId;
|
||||
}
|
||||
|
||||
function ProductCard({
|
||||
accent,
|
||||
badge,
|
||||
title,
|
||||
blurb,
|
||||
cta,
|
||||
target,
|
||||
}: ProductCardProps) {
|
||||
const { setActiveView } = useView();
|
||||
return (
|
||||
<Card accent={accent} padding="loose" interactive>
|
||||
<div className="portal-home__product-head">
|
||||
<h3 className="portal-home__product-title">{title}</h3>
|
||||
{badge && (
|
||||
<StatusBadge
|
||||
tone={accent === "default" ? "info" : "purple"}
|
||||
size="sm"
|
||||
>
|
||||
{badge}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<p className="portal-home__product-blurb">{blurb}</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
accent={accent}
|
||||
size="sm"
|
||||
onClick={() => setActiveView(target)}
|
||||
rightSection={<span aria-hidden>→</span>}
|
||||
>
|
||||
{cta}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductGrid() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section
|
||||
className="portal-home__product-grid"
|
||||
aria-label={t("portal.home.productGrid.ariaLabel")}
|
||||
>
|
||||
<ProductCard
|
||||
accent="premium"
|
||||
title={t("portal.home.productGrid.sources.title")}
|
||||
blurb={t("portal.home.productGrid.sources.blurb")}
|
||||
cta={t("portal.home.productGrid.sources.cta")}
|
||||
target="sources"
|
||||
/>
|
||||
<ProductCard
|
||||
accent="default"
|
||||
badge={t("portal.home.productGrid.pipelines.badge")}
|
||||
title={t("portal.home.productGrid.pipelines.title")}
|
||||
blurb={t("portal.home.productGrid.pipelines.blurb")}
|
||||
cta={t("portal.home.productGrid.pipelines.cta")}
|
||||
target="pipelines"
|
||||
/>
|
||||
<ProductCard
|
||||
accent="premium"
|
||||
title={t("portal.home.productGrid.agents.title")}
|
||||
blurb={t("portal.home.productGrid.agents.blurb")}
|
||||
cta={t("portal.home.productGrid.agents.cta")}
|
||||
target="sources"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Quick actions card */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function QuickActions({ onTryOp }: { onTryOp: () => void }) {
|
||||
function QuickActions() {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useView();
|
||||
return (
|
||||
@@ -131,30 +27,6 @@ function QuickActions({ onTryOp }: { onTryOp: () => void }) {
|
||||
</span>
|
||||
</div>
|
||||
<div className="portal-home__quick-list">
|
||||
<Button
|
||||
variant="quiet"
|
||||
type="button"
|
||||
className="portal-home__quick-row"
|
||||
onClick={onTryOp}
|
||||
>
|
||||
<span
|
||||
className="portal-home__quick-icon"
|
||||
style={{
|
||||
background: "var(--color-blue-light)",
|
||||
color: "var(--color-blue)",
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
<span className="portal-home__quick-text">
|
||||
<strong>{t("portal.home.quickActions.tryOp.title")}</strong>
|
||||
<span>{t("portal.home.quickActions.tryOp.blurb")}</span>
|
||||
</span>
|
||||
<span className="portal-home__quick-arrow" aria-hidden>
|
||||
→
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="quiet"
|
||||
type="button"
|
||||
@@ -232,201 +104,12 @@ function QuickActions({ onTryOp }: { onTryOp: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Tier KPI strip */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
interface KpiLabelSpec {
|
||||
labelKey: string;
|
||||
descriptionKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* KPI labels are product copy — they describe what each metric IS, not what
|
||||
* its current value is. Labels stay client-side so the strip's structure is
|
||||
* stable across loading / error / ready states. Only the values + deltas
|
||||
* flow through the API. Stored as i18n keys, resolved at render time.
|
||||
*/
|
||||
const KPI_LABELS_BY_TIER: Record<Tier, KpiLabelSpec[]> = {
|
||||
free: [
|
||||
{
|
||||
labelKey: "portal.home.kpis.free.docsProcessed",
|
||||
descriptionKey: "portal.home.kpis.free.docsProcessedDescription",
|
||||
},
|
||||
{ labelKey: "portal.home.kpis.free.operations" },
|
||||
{ labelKey: "portal.home.kpis.free.pipelines" },
|
||||
{ labelKey: "portal.home.kpis.free.agents" },
|
||||
],
|
||||
pro: [
|
||||
{ labelKey: "portal.home.kpis.pro.docs30d" },
|
||||
{ labelKey: "portal.home.kpis.pro.pipelines" },
|
||||
{ labelKey: "portal.home.kpis.pro.agentsActive" },
|
||||
{ labelKey: "portal.home.kpis.pro.evalPassRate" },
|
||||
],
|
||||
enterprise: [
|
||||
{ labelKey: "portal.home.kpis.enterprise.docs30d" },
|
||||
{ labelKey: "portal.home.kpis.enterprise.p95Latency" },
|
||||
{ labelKey: "portal.home.kpis.enterprise.evalPassRate" },
|
||||
{ labelKey: "portal.home.kpis.enterprise.slaUptime" },
|
||||
],
|
||||
};
|
||||
|
||||
function TierKpiStrip() {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
const labels = KPI_LABELS_BY_TIER[tier];
|
||||
const { data: kpis, loading } = useAsync<KpiEntry[]>(
|
||||
() => fetchHomeKpis(tier),
|
||||
[tier],
|
||||
);
|
||||
|
||||
return (
|
||||
<MetricStrip>
|
||||
{labels.map((spec, i) => {
|
||||
// useAsync keeps the previous tier's data during a refetch; ignore it
|
||||
// while loading so the new labels never pair with stale values.
|
||||
const fetched = loading ? undefined : kpis?.[i];
|
||||
return (
|
||||
<MetricCard
|
||||
key={`${tier}-${i}`}
|
||||
label={t(spec.labelKey)}
|
||||
value={fetched?.value ?? "—"}
|
||||
delta={fetched?.delta}
|
||||
deltaDirection={fetched?.deltaDirection}
|
||||
description={
|
||||
spec.descriptionKey
|
||||
? t(spec.descriptionKey)
|
||||
: fetched?.description
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</MetricStrip>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Enterprise region health */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const REGION_DOT: Record<RegionHealth["status"], string> = {
|
||||
healthy: "var(--color-green)",
|
||||
degraded: "var(--color-amber)",
|
||||
down: "var(--color-red)",
|
||||
};
|
||||
|
||||
function EnterpriseRegions() {
|
||||
const { t } = useTranslation();
|
||||
const state = useAsync<RegionHealth[]>(() => fetchRegionHealth(), []);
|
||||
const { data: regions } = state;
|
||||
const { isLoading, isEmpty } = useSectionFlags(state);
|
||||
|
||||
return (
|
||||
<section className="portal-home__region-block">
|
||||
<header className="portal-home__region-header">
|
||||
<h2 className="portal-home__region-title">
|
||||
{t("portal.home.regions.title")}
|
||||
</h2>
|
||||
<p className="portal-home__region-sub">
|
||||
{t("portal.home.regions.subtitle")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{isLoading && (
|
||||
<div className="portal-home__regions" aria-hidden>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="portal-home__region">
|
||||
<Skeleton width="6rem" />
|
||||
<Skeleton width="80%" height="0.625rem" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={t("portal.home.regions.empty.title")}
|
||||
description={t("portal.home.regions.empty.description")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{regions && regions.length > 0 && (
|
||||
<div className="portal-home__regions">
|
||||
{regions.map((r) => (
|
||||
<div key={r.name} className="portal-home__region">
|
||||
<div className="portal-home__region-name">
|
||||
<span
|
||||
className="portal-home__region-dot"
|
||||
style={{ background: REGION_DOT[r.status] }}
|
||||
aria-hidden
|
||||
/>
|
||||
{r.name}
|
||||
<span className="sr-only">{` — ${r.status}`}</span>
|
||||
</div>
|
||||
<div className="portal-home__region-meta">{r.meta}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Chart section */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function ChartSection() {
|
||||
const { t } = useTranslation();
|
||||
const state = useAsync<UsageSeriesResponse>(() => fetchUsageSeries(), []);
|
||||
const { data: usage } = state;
|
||||
const { isLoading } = useSectionFlags(state);
|
||||
|
||||
const docs30d = useMemo(
|
||||
() => usage?.points.reduce((sum, p) => sum + p.value, 0) ?? 0,
|
||||
[usage],
|
||||
);
|
||||
const deltaPct = useMemo(() => {
|
||||
if (!usage || usage.priorTotal <= 0) return undefined;
|
||||
return (docs30d - usage.priorTotal) / usage.priorTotal;
|
||||
}, [usage, docs30d]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="portal-chart">
|
||||
<Skeleton width="12rem" height="0.875rem" />
|
||||
<Skeleton width="7rem" height="1.75rem" />
|
||||
<Skeleton height="15rem" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!usage || usage.points.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={t("portal.home.chart.empty.title")}
|
||||
description={t("portal.home.chart.empty.description")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UsageAreaChart
|
||||
data={usage.points}
|
||||
totalValue={docs30d.toLocaleString()}
|
||||
deltaPct={deltaPct}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Home view */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export function Home() {
|
||||
const { tier } = useTier();
|
||||
const [runnerOpen, setRunnerOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="portal-home">
|
||||
@@ -437,52 +120,14 @@ export function Home() {
|
||||
deal is underway (a bolt-on to any tier), otherwise the setup checklist. */}
|
||||
<HomeHero tier={tier} />
|
||||
|
||||
{tier === "free" && (
|
||||
<>
|
||||
<ProcessingStatusStrip />
|
||||
<TierKpiStrip />
|
||||
<QuickActions onTryOp={() => setRunnerOpen(true)} />
|
||||
<PolicySummary />
|
||||
<PipelineForkWizard />
|
||||
<ProductGrid />
|
||||
<PopularUseCases />
|
||||
</>
|
||||
)}
|
||||
|
||||
{tier === "pro" && (
|
||||
<>
|
||||
<ProcessingStatusStrip />
|
||||
<ChartSection />
|
||||
<TierKpiStrip />
|
||||
<div className="portal-home__row">
|
||||
<RecentActivity />
|
||||
<QuickActions onTryOp={() => setRunnerOpen(true)} />
|
||||
</div>
|
||||
<PolicySummary />
|
||||
<PipelineForkWizard />
|
||||
<ProductGrid />
|
||||
<PopularUseCases />
|
||||
</>
|
||||
)}
|
||||
|
||||
{tier === "enterprise" && (
|
||||
<>
|
||||
<ProcessingStatusStrip />
|
||||
<ChartSection />
|
||||
<TierKpiStrip />
|
||||
<EnterpriseRegions />
|
||||
<div className="portal-home__row">
|
||||
<RecentActivity />
|
||||
<QuickActions onTryOp={() => setRunnerOpen(true)} />
|
||||
</div>
|
||||
<PolicySummary />
|
||||
<PipelineForkWizard />
|
||||
<ProductGrid />
|
||||
<PopularUseCases />
|
||||
</>
|
||||
)}
|
||||
|
||||
<SingleOpRunner open={runnerOpen} onClose={() => setRunnerOpen(false)} />
|
||||
{/* One unified layout across tiers: real processed-PDF volume, real audit
|
||||
activity, quick actions, and the standing-policy summary. */}
|
||||
<ProcessingStatusStrip />
|
||||
<div className="portal-home__row">
|
||||
<RecentActivity />
|
||||
<QuickActions />
|
||||
</div>
|
||||
<PolicySummary />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user