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:
ConnorYoh
2026-07-09 11:58:32 +00:00
committed by GitHub
parent e5a258a648
commit d3638d786d
27 changed files with 89 additions and 3221 deletions
@@ -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"
-44
View File
@@ -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");
}
-37
View File
@@ -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),
});
}),
];
-285
View File
@@ -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.
-166
View File
@@ -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,
},
};
-79
View File
@@ -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 });
}),
],
},
},
};
+12 -367
View File
@@ -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>
);
}