diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index fd3673ca1a..4f358cc634 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -43,14 +43,32 @@ function parseEnTranslation(): Record { } } +const enTranslationResources = parseEnTranslation(); if (!i18next.isInitialized) { + // initImmediate: false → initialise synchronously from the inline resources + // (there's no async backend here), so i18next is ready before the first story + // renders. Without it the first render can beat init and stick on raw keys. void i18next.use(initReactI18next).init({ - lng: "en", - fallbackLng: "en", - resources: { en: { translation: parseEnTranslation() } }, + lng: "en-US", + fallbackLng: "en-US", + supportedLngs: ["en-US"], + resources: { "en-US": { translation: enTranslationResources } }, interpolation: { escapeValue: false }, react: { useSuspense: false }, + initImmediate: false, }); +} else { + // Something initialised i18next first (e.g. the app's async TOML backend): + // inject the shipped English copy under the resolved language so t() renders + // real copy, not raw keys. + const lng = i18next.resolvedLanguage || i18next.language || "en-US"; + i18next.addResourceBundle( + lng, + "translation", + enTranslationResources, + true, + true, + ); } // Start MSW once. Storybook runs in a browser so this uses the service worker. diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 6be49df2e0..fefa9c7e3f 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7641,6 +7641,48 @@ soon = "Soon" managePlan = "Manage plan" volumeSuffix = "PDFs processed · last 30 days" +[portal.processorFlow] +footnote = "Counts are over the last 24 hours. Flow speed is illustrative." +liveBadge = "Live" +stats = "{{connected}} connected · {{processed}} PDFs processed" +title = "PDF Processor" + +[portal.processorFlow.lens] +ariaLabel = "View mode" +flow = "Flow" +sankey = "Sankey" + +[portal.processorFlow.outcomes] +count = "{{n}} · 24h" +failed = "Failed" +heading = "Outcomes" +success = "Delivered" + +[portal.processorFlow.policies] +activeCount = "{{n}} active" +count = "{{n}} · 24h" +heading = "Policies" +setUp = "Set up" +soon = "Soon" + +[portal.processorFlow.sankey] +waist = "Policies · {{n}} active" + +[portal.processorFlow.sankey.empty] +description = "Connect a source and switch on a policy to watch documents flow through the processor." +title = "No flow yet" + +[portal.processorFlow.sources] +comingSoonTag = "Connect" +editor = "Stirling PDF Editor" +heading = "Sources" +perDay = "{{n}} / 24h" + +[portal.processorFlow.sources.comingSoon] +apiMcp = "API · MCP" +cloud = "Drive · Box · S3" +email = "Email intake" + [portal.procurement] subtitle = "Get your team evaluated, contracted, and onboarded. Every document in one place." title = "Procurement" diff --git a/frontend/editor/src/core/ui/StatusBadge.css b/frontend/editor/src/core/ui/StatusBadge.css index a250f99d9a..9115eb6099 100644 --- a/frontend/editor/src/core/ui/StatusBadge.css +++ b/frontend/editor/src/core/ui/StatusBadge.css @@ -8,6 +8,17 @@ letter-spacing: 0.01em; border: 1px solid transparent; line-height: 1; + color: var(--sui-status-c, var(--color-text-3)); + background: color-mix( + in srgb, + var(--sui-status-c, var(--color-text-3)) 12%, + transparent + ); + border-color: color-mix( + in srgb, + var(--sui-status-c, var(--color-text-3)) 28%, + transparent + ); } .sui-status--sm { font-size: 0.6875rem; @@ -38,33 +49,27 @@ animation: pulseRing 1.4s ease-out infinite; } +/* Neutral keeps the plain muted surface rather than an accent tint. */ .sui-status--neutral { color: var(--color-text-3); background: var(--color-bg-muted); border-color: var(--color-border-light); } +/* Accent tones only pick the accent; the base rule builds the fill + border. + The `-dark` variants are theme-adaptive (darker in light, brighter in dark), + so the text stays legible on the pale fill in both themes. */ .sui-status--success { - color: var(--color-green); - background: var(--color-green-light); - border-color: var(--color-green-border); + --sui-status-c: var(--color-green-dark); } .sui-status--warning { - color: var(--color-amber-dark); - background: var(--color-amber-light); - border-color: var(--color-amber-border); + --sui-status-c: var(--color-amber-dark); } .sui-status--danger { - color: var(--color-red); - background: var(--color-red-light); - border-color: var(--color-red-border); + --sui-status-c: var(--color-red-dark); } .sui-status--info { - color: var(--color-blue); - background: var(--color-blue-light); - border-color: var(--color-blue-border); + --sui-status-c: var(--color-blue-dark); } .sui-status--purple { - color: var(--color-purple); - background: var(--color-purple-light); - border-color: var(--color-purple-border); + --sui-status-c: var(--color-purple-dark); } diff --git a/frontend/editor/src/portal/api/processorFlow.ts b/frontend/editor/src/portal/api/processorFlow.ts new file mode 100644 index 0000000000..30a9b468a6 --- /dev/null +++ b/frontend/editor/src/portal/api/processorFlow.ts @@ -0,0 +1,173 @@ +/** + * Processor-flow assembler for the home visualiser. + * + * Fans in the three real portal surfaces — sources (`/api/v1/sources`), policies + * (`/api/v1/policies`) and their runs (`/api/v1/policies/runs`) — and derives the + * left→middle→right shape the {@link ProcessorFlow} component renders: + * + * sources → policies → outcomes + * + * Everything here is real backend data. Per-run source attribution does not + * exist (a `PolicyRunView` carries `policyId` but no source id), so the flow + * animation is illustrative; the node counts are not — each source's `docs24h`, + * each policy's trailing-24h run count, and the success/failure split all come + * straight from the API. + */ + +import { apiClient } from "@portal/api/http"; +import { fetchSources } from "@portal/api/sources"; +import { POLICY_CATEGORIES } from "@portal/api/policies"; +import { fromWirePolicy } from "@app/policies/codec"; +import type { PolicyRunView, WirePolicy } from "@app/policies/types"; + +/** A source that actually feeds the processor today (editor, folder, S3, …). */ +export interface FlowSource { + id: string; + /** Display name (already resolved; editor rows get a friendly label). */ + name: string; + type: string; + /** Documents this source fed into runs over the trailing 24h. */ + docs24h: number; +} + +/** + * A connector type shown in the sources column but not yet a real source type — + * a "coming soon" affordance only. `labelKey` is an i18n key. + */ +export interface FlowComingSoonSource { + key: string; + labelKey: string; +} + +/** + * Row display state, mirroring the Policies page: + * - `active` — configured + enabled; shows its live 24h run count + * - `off` — available but not set up; offers a "Set up" CTA + * - `locked` — a coming-soon category that doesn't exist yet + */ +export type FlowPolicyState = "active" | "off" | "locked"; + +/** + * One row in the middle policies column — the full policy catalogue, in the + * same order the Policies page shows, including the coming-soon categories. + */ +export interface FlowPolicy { + /** Category id (also the lane key for the flow animation + its icon). */ + key: string; + /** i18n key for the category label. */ + labelKey: string; + state: FlowPolicyState; + configured: boolean; + runs24h: number; +} + +export type FlowOutcomeKey = "success" | "failed"; + +/** A terminal audit outcome node on the right, counted over the trailing 24h. */ +export interface FlowOutcome { + key: FlowOutcomeKey; + labelKey: string; + count24h: number; +} + +export interface ProcessorFlow { + sources: FlowSource[]; + comingSoonSources: FlowComingSoonSource[]; + policies: FlowPolicy[]; + outcomes: FlowOutcome[]; +} + +const DAY_MS = 86_400_000; + +/** Connector types the sources column advertises but can't create yet. */ +const COMING_SOON_SOURCES: FlowComingSoonSource[] = [ + { key: "apiMcp", labelKey: "portal.processorFlow.sources.comingSoon.apiMcp" }, + { + key: "cloud", + labelKey: "portal.processorFlow.sources.comingSoon.cloud", + }, + { + key: "email", + labelKey: "portal.processorFlow.sources.comingSoon.email", + }, +]; + +/** + * The full policy catalogue, in the Policies-page order, including the + * coming-soon categories (rendered as locked). `active` rows carry their + * trailing-24h run count. + */ +function buildPolicies( + wirePolicies: WirePolicy[], + runs: PolicyRunView[], +): FlowPolicy[] { + const cutoff = Date.now() - DAY_MS; + const decoded = wirePolicies.map(fromWirePolicy); + + return POLICY_CATEGORIES.map((cat) => { + const dp = decoded.find((p) => p.categoryId === cat.id); + const configured = Boolean(dp?.enabled); + const state: FlowPolicyState = configured + ? "active" + : cat.comingSoon + ? "locked" + : "off"; + const runs24h = dp + ? runs.filter((r) => r.policyId === dp.id && r.createdAt >= cutoff).length + : 0; + return { + key: cat.id, + labelKey: cat.label, + state, + configured, + runs24h, + }; + }); +} + +/** Terminal audit outcomes over the trailing 24h — success vs failure. */ +function buildOutcomes(runs: PolicyRunView[]): FlowOutcome[] { + const cutoff = Date.now() - DAY_MS; + const recent = runs.filter((r) => r.createdAt >= cutoff); + const success = recent.filter((r) => r.status === "COMPLETED").length; + const failed = recent.filter( + (r) => r.status === "FAILED" || r.status === "CANCELLED", + ).length; + return [ + { + key: "success", + labelKey: "portal.processorFlow.outcomes.success", + count24h: success, + }, + { + key: "failed", + labelKey: "portal.processorFlow.outcomes.failed", + count24h: failed, + }, + ]; +} + +/** Assemble the full flow model from the three live portal surfaces. */ +export async function fetchProcessorFlow(): Promise { + const [sourcesResp, wirePolicies, runs] = await Promise.all([ + fetchSources(), + apiClient.local.json("/api/v1/policies"), + apiClient.local + .json("/api/v1/policies/runs") + .catch(() => [] as PolicyRunView[]), + ]); + + const sources: FlowSource[] = sourcesResp.sources.map((s) => ({ + id: s.id, + name: s.name, + type: s.type, + docs24h: s.docs24h, + })); + + return { + sources, + comingSoonSources: COMING_SOON_SOURCES, + policies: buildPolicies(wirePolicies, runs), + outcomes: buildOutcomes(runs), + }; +} diff --git a/frontend/editor/src/portal/components/ProcessorFlow.css b/frontend/editor/src/portal/components/ProcessorFlow.css new file mode 100644 index 0000000000..5aa7f6198f --- /dev/null +++ b/frontend/editor/src/portal/components/ProcessorFlow.css @@ -0,0 +1,394 @@ +/* Processor-flow visualiser — sources → policies → outcomes. The flow is an + SVG overlay measured from the HTML cards: bézier wires underneath, and a + rAF-driven particle layer on top (see ProcessorFlow.tsx). */ + +.portal-pf { + --pf-accent: var(--color-green); +} + +/* ── Header ──────────────────────────────────────────────────────────────── */ +.portal-pf__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.portal-pf__head-text { + display: flex; + align-items: baseline; + gap: 0.5rem; + min-width: 0; +} + +.portal-pf__head-actions { + display: flex; + align-items: center; + gap: 0.625rem; + flex: none; +} + +.portal-pf__live { + align-self: center; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--color-border); +} + +.portal-pf__live--on { + background: var(--pf-accent); + box-shadow: 0 0 0 0 color-mix(in srgb, var(--pf-accent) 55%, transparent); + animation: pf-pulse 2.4s ease-out infinite; +} + +.portal-pf__title { + margin: 0; + font-size: 0.9375rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-pf__connected { + font-size: 0.75rem; + color: var(--color-text-4); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Stage: relative box the SVG overlays are measured against ───────────── */ +.portal-pf__stage { + position: relative; + padding: 0.25rem 0 0.5rem; +} + +.portal-pf__wires, +.portal-pf__particles { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + overflow: visible; +} + +.portal-pf__wires { + z-index: 0; +} + +.portal-pf__particles { + z-index: 2; +} + +.portal-pf__wire-path { + fill: none; + stroke: var(--color-border-light); + stroke-width: 1.25; +} + +/* ── Columns ─────────────────────────────────────────────────────────────── */ +.portal-pf__cols { + position: relative; + z-index: 1; + display: flex; + justify-content: space-between; + align-items: stretch; + gap: 1rem; +} + +.portal-pf__col { + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.5rem; + flex: none; + width: 15rem; +} + +.portal-pf__col-head, +.portal-pf__policies-head { + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--color-text-5); + margin-bottom: 0.125rem; +} + +/* ── Nodes (source + outcome cards) ─────────────────────────────────────────── */ +.portal-pf__node { + height: auto; + width: 100%; + text-align: left; + background: var(--color-bg); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); + transition: + background var(--motion-fast), + border-color var(--motion-fast); +} + +.portal-pf__node .mantine-Button-label { + flex: 1 1 auto; + justify-content: flex-start; + overflow: visible; +} + +.portal-pf__node:hover { + background: var(--color-bg-hover); + border-color: var(--color-border); +} + +.portal-pf__node:focus-visible { + outline: 2px solid var(--color-blue); + outline-offset: 2px; +} + +.portal-pf__node--soon { + background: transparent; + border-style: dashed; + opacity: 0.75; +} + +.portal-pf__node-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: 1.75rem; + height: 1.75rem; + border-radius: var(--radius-md); + font-size: 0.9375rem; + background: var(--color-bg-muted); + color: var(--color-text-3); +} + +.portal-pf__node--soon .portal-pf__node-icon { + color: var(--color-text-5); + font-weight: 600; +} + +.portal-pf__node--success .portal-pf__node-icon { + background: color-mix(in srgb, var(--color-green) 14%, transparent); + color: var(--color-green-dark); +} + +.portal-pf__node--failed .portal-pf__node-icon { + background: color-mix(in srgb, var(--color-red) 14%, transparent); + color: var(--color-red-dark); +} + +.portal-pf__node-text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.portal-pf__node-text strong { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.portal-pf__node-text span { + font-size: 0.6875rem; + color: var(--color-text-4); +} + +/* ── Policies card (core) ───────────────────────────────────────────────────── */ +.portal-pf__policies { + align-self: center; + flex: none; + width: 18rem; + padding: 0.75rem; + background: var(--color-bg); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} + +.portal-pf__policies-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0.25rem 0.5rem; +} + +.portal-pf__policies-active { + text-transform: none; + letter-spacing: 0; + color: var(--color-green-dark); +} + +.portal-pf__policy { + padding: 0.5rem 0.25rem; +} + +.portal-pf__policy + .portal-pf__policy { + border-top: 1px solid var(--color-border-light); +} + +.portal-pf__policy-line { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.portal-pf__policy-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: 1.5rem; + height: 1.5rem; + border-radius: 6px; + font-size: 0.875rem; + color: var(--color-text-4); + transition: + color 0.3s ease, + background-color 0.3s ease, + box-shadow 0.3s ease; +} + +.portal-pf__policy--active .portal-pf__policy-icon { + color: var(--pf-accent); +} + +.portal-pf__policy--locked .portal-pf__policy-icon { + color: var(--color-text-5); +} + +/* Leading-LED blink: added for 150ms as a particle threads this row's lane, + then eased back out by the transition above. */ +.portal-pf__policy-icon.is-pulse { + color: var(--color-green-dark); + background-color: color-mix(in srgb, var(--color-green) 20%, transparent); + box-shadow: 0 0 8px 1px + color-mix(in srgb, var(--color-green) 55%, transparent); +} + +.portal-pf__policy-label { + flex: 1 1 auto; + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-2); + min-width: 0; +} + +.portal-pf__policy--active .portal-pf__policy-label { + color: var(--color-text-1); +} + +.portal-pf__policy--locked .portal-pf__policy-label { + color: var(--color-text-4); + font-weight: 500; +} + +.portal-pf__policy-count { + font-size: 0.75rem; + color: var(--color-text-4); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.portal-pf__policy-soon { + flex: none; + font-size: 0.6875rem; + font-weight: 600; + color: var(--color-text-5); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +/* Vertical padding via the `py` prop; height auto so it grows with it. */ +.portal-pf__setup { + flex: none; + height: auto; +} + +/* ── Footnote / loading ─────────────────────────────────────────────────────── */ +.portal-pf__foot { + margin: 0.875rem 0 0; + font-size: 0.6875rem; + color: var(--color-text-5); +} + +.portal-pf__loading { + padding: 0.5rem 0; +} + +/* ── Sankey lens ────────────────────────────────────────────────────────────── */ +.portal-pf__sankey { + max-width: 46rem; + margin: 0.5rem auto 0; +} + +.portal-pf__sankey svg { + display: block; + width: 100%; + height: auto; + overflow: visible; +} + +.portal-pf__sankey-label { + font-size: 12px; + font-weight: 600; + fill: var(--color-text-2); + font-variant-numeric: tabular-nums; +} + +.portal-pf__sankey-caption { + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + fill: var(--color-text-5); +} + +.portal-pf__sankey-empty { + display: flex; + align-items: center; + justify-content: center; + min-height: 11rem; +} + +@keyframes pf-pulse { + 0% { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--pf-accent) 55%, transparent); + } + 70% { + box-shadow: 0 0 0 0.4rem transparent; + } + 100% { + box-shadow: 0 0 0 0 transparent; + } +} + +/* ── Responsive: stack the columns; the measured-geometry flow overlay only + makes sense on the wide 3-column layout, so drop it below the breakpoint. ── */ +@media (max-width: 60rem) { + .portal-pf__wires, + .portal-pf__particles { + display: none; + } + .portal-pf__cols { + flex-direction: column; + gap: 0.75rem; + } + .portal-pf__col, + .portal-pf__policies { + width: 100%; + align-self: stretch; + } +} + +@media (prefers-reduced-motion: reduce) { + .portal-pf__live--on { + animation: none; + } +} diff --git a/frontend/editor/src/portal/components/ProcessorFlow.stories.tsx b/frontend/editor/src/portal/components/ProcessorFlow.stories.tsx new file mode 100644 index 0000000000..e573751ac6 --- /dev/null +++ b/frontend/editor/src/portal/components/ProcessorFlow.stories.tsx @@ -0,0 +1,60 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { ProcessorFlow } from "@portal/components/ProcessorFlow"; + +/** + * The home processor visualiser. Data is served by the global portal MSW + * handlers (seeded sources + one active Security policy + its runs), so the + * middle column shows Security "active" and Classification with a "Set up" CTA, + * and the outcomes reflect the seeded 24h success/failure split. + * + * NB: the flow particles animate via requestAnimationFrame, which browsers + * pause while the tab/preview is hidden — open the story in a focused tab to + * see the dots move. + */ +const meta: Meta = { + title: "Portal/Components/ProcessorFlow", + component: ProcessorFlow, + parameters: { layout: "padded" }, +}; +export default meta; + +type Story = StoryObj; + +/** Live machine: Security configured + real throughput → the flow runs. */ +export const Default: Story = {}; + +/** + * Nothing set up and no activity — the empty state from the design. In + * production the flow stays still here; the DEV_KEEP_FLOWING dev flag forces it + * on with synthetic rates so the animation is visible while iterating. + */ +export const IdleEmpty: Story = { + parameters: { + msw: { + handlers: [ + http.get("/api/v1/sources", () => + HttpResponse.json({ + kpis: [], + sources: [ + { + id: "editor", + name: "Editor", + type: "editor", + status: "active", + referenceCount: 0, + referencingPolicies: [], + config: [], + docsTotal: 0, + docs24h: 0, + docs30d: 0, + }, + ], + }), + ), + http.get("/api/v1/policies", () => HttpResponse.json([])), + http.get("/api/v1/policies/runs", () => HttpResponse.json([])), + ], + }, + }, +}; diff --git a/frontend/editor/src/portal/components/ProcessorFlow.tsx b/frontend/editor/src/portal/components/ProcessorFlow.tsx new file mode 100644 index 0000000000..4b4a832ad3 --- /dev/null +++ b/frontend/editor/src/portal/components/ProcessorFlow.tsx @@ -0,0 +1,184 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Card, SegmentedControl, Skeleton, StatusBadge } from "@app/ui"; +import { + useView, + VIEW_PATHS, + toPortalPath, +} from "@portal/contexts/ViewContext"; +import { useAsync } from "@portal/hooks/useAsync"; +import { + fetchProcessorFlow, + type ProcessorFlow as ProcessorFlowModel, +} from "@portal/api/processorFlow"; +import { + DEV_KEEP_FLOWING, + DEV_SYNTH_RATE, + type Lens, +} from "@portal/components/processor-flow/flowTypes"; +import { useFlowGeometry } from "@portal/components/processor-flow/useFlowGeometry"; +import { useFlowParticles } from "@portal/components/processor-flow/useFlowParticles"; +import { FlowSources } from "@portal/components/processor-flow/FlowSources"; +import { FlowPolicies } from "@portal/components/processor-flow/FlowPolicies"; +import { FlowOutcomes } from "@portal/components/processor-flow/FlowOutcomes"; +import { FlowSankey } from "@portal/components/processor-flow/FlowSankey"; +import "@portal/components/ProcessorFlow.css"; + +/** + * Animated processor visualiser for the home surface: connected sources on the + * left flow through the standing policies in the middle to their audit outcomes + * on the right. Two lenses — a live particle flow and a Sankey summary. + * + * This module wires the data + gating together; the moving parts live under + * `processor-flow/`: geometry ({@link useFlowGeometry}), the rAF particle loop + * ({@link useFlowParticles}), the three columns, and the Sankey. The flow only + * runs when something is set up AND there's activity; an idle machine stays + * still (unless {@link DEV_KEEP_FLOWING} forces it while iterating). + */ +export function ProcessorFlow() { + const { t } = useTranslation(); + const { setActiveView } = useView(); + const navigate = useNavigate(); + const { data, loading } = useAsync( + () => fetchProcessorFlow(), + [], + ); + + const [lens, setLens] = useState("flow"); + const isLoading = loading && data === null; + + /** Deep-link to the Policies page and auto-open that policy's setup wizard. */ + const openPolicySetup = (key: string) => + navigate( + `${toPortalPath(VIEW_PATHS.policies)}?setup=${encodeURIComponent(key)}`, + ); + + /** Deep-link to Infrastructure with the audit-log tab open. */ + const openAuditLog = () => + navigate(`${toPortalPath(VIEW_PATHS.infrastructure)}?tab=audit`); + + const sources = data?.sources ?? []; + const policies = data?.policies ?? []; + const outcomes = data?.outcomes ?? []; + const comingSoonSources = data?.comingSoonSources ?? []; + + // ── Flow gating: run only when something is set up AND there's activity. + const totalRate = sources.reduce((sum, s) => sum + s.docs24h, 0); + const hasConfigured = policies.some((p) => p.configured); + const liveFlow = hasConfigured && totalRate > 0; + // When forcing for dev with no live flow, synthesise rates + thread every row. + const devForced = DEV_KEEP_FLOWING && !liveFlow; + const animate = liveFlow || devForced; + + // Particles only thread configured (active) policies; while dev-forcing with + // no live flow, thread the available (non-locked) rows so the demo has lanes. + const laneKeys = policies + .filter((p) => (devForced ? p.state !== "locked" : p.state === "active")) + .map((p) => p.key); + + const activeCount = policies.filter((p) => p.state === "active").length; + const pdfsProcessed = outcomes.reduce((sum, o) => sum + o.count24h, 0); + const statsLabel = t("portal.processorFlow.stats", { + connected: sources.length, + processed: pdfsProcessed.toLocaleString(), + }); + + // Per-source rates + outcome weights feeding the particle loop. + const rates = sources.map((s) => (devForced ? DEV_SYNTH_RATE : s.docs24h)); + const weights = (() => { + const raw = outcomes.map((o) => o.count24h); + const sum = raw.reduce((a, b) => a + b, 0); + if (sum > 0) return raw.map((v) => v / sum); + // No real outcomes yet (dev flow): success-heavy default. + return outcomes.map((o) => (o.key === "failed" ? 0.15 : 0.85)); + })(); + const outcomeKeys = outcomes.map((o) => o.key); + + const { wrapRef, srcRefs, outRefs, coreRef, laneRefs, geoRef, wires } = + useFlowGeometry(); + const pGroupRef = useFlowParticles({ + geoRef, + animate, + lens, + rates, + weights, + laneKeys, + outcomeKeys, + }); + + return ( + +
+
+ +

+ {t("portal.processorFlow.title")} +

+ {statsLabel} +
+
+ + {t("portal.processorFlow.liveBadge")} + + + size="xs" + value={lens} + onChange={setLens} + ariaLabel={t("portal.processorFlow.lens.ariaLabel")} + options={[ + { label: t("portal.processorFlow.lens.flow"), value: "flow" }, + { label: t("portal.processorFlow.lens.sankey"), value: "sankey" }, + ]} + /> +
+
+ + {isLoading ? ( +
+ +
+ ) : lens === "sankey" ? ( + + ) : ( +
+ + {wires} + + +
+ setActiveView("sources")} + /> + + +
+ + + + +
+ )} + +

{t("portal.processorFlow.footnote")}

+
+ ); +} diff --git a/frontend/editor/src/portal/components/processor-flow/FlowIcons.tsx b/frontend/editor/src/portal/components/processor-flow/FlowIcons.tsx new file mode 100644 index 0000000000..8cb83ab444 --- /dev/null +++ b/frontend/editor/src/portal/components/processor-flow/FlowIcons.tsx @@ -0,0 +1,28 @@ +import LocalIcon from "@app/components/shared/LocalIcon"; +import type { FlowOutcomeKey } from "@portal/api/processorFlow"; +import { + EDITOR_TYPE, + ICON_SIZE, +} from "@portal/components/processor-flow/flowTypes"; + +/** Real Material Symbols icon for a live source node, keyed off its `type`. */ +export function SourceIcon({ type }: { type: string }) { + switch (type) { + case EDITOR_TYPE: + return ; + case "s3": + return ; + case "folder": + return ; + default: + return ; + } +} + +/** Real Material Symbols icon for an audit outcome node. Literal icon names + * (not a ternary) so the icon extractor bundles them. */ +export function OutcomeIcon({ outcome }: { outcome: FlowOutcomeKey }) { + if (outcome === "success") + return ; + return ; +} diff --git a/frontend/editor/src/portal/components/processor-flow/FlowOutcomes.tsx b/frontend/editor/src/portal/components/processor-flow/FlowOutcomes.tsx new file mode 100644 index 0000000000..1a3eeabfde --- /dev/null +++ b/frontend/editor/src/portal/components/processor-flow/FlowOutcomes.tsx @@ -0,0 +1,59 @@ +import { type RefObject } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui"; +import type { FlowOutcome } from "@portal/api/processorFlow"; +import { OutcomeIcon } from "@portal/components/processor-flow/FlowIcons"; + +interface FlowOutcomesProps { + outcomes: FlowOutcome[]; + /** One ref slot per outcome, in order, for geometry measurement. */ + outRefs: RefObject<(HTMLElement | null)[]>; + onOpen: () => void; +} + +/** Right column: terminal audit outcomes (delivered / failed). */ +export function FlowOutcomes({ outcomes, outRefs, onOpen }: FlowOutcomesProps) { + const { t } = useTranslation(); + return ( +
+ + {t("portal.processorFlow.outcomes.heading")} + + {outcomes.map((outcome, j) => ( + + ))} +
+ ); +} diff --git a/frontend/editor/src/portal/components/processor-flow/FlowPolicies.tsx b/frontend/editor/src/portal/components/processor-flow/FlowPolicies.tsx new file mode 100644 index 0000000000..f01d51c44d --- /dev/null +++ b/frontend/editor/src/portal/components/processor-flow/FlowPolicies.tsx @@ -0,0 +1,79 @@ +import { type RefObject } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui"; +import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon"; +import type { FlowPolicy } from "@portal/api/processorFlow"; + +interface FlowPoliciesProps { + policies: FlowPolicy[]; + activeCount: number; + /** Ref for the core card (measured as the particle waist). */ + coreRef: RefObject; + /** Per-policy lane-line refs, keyed by policy id, for particle threading. */ + laneRefs: RefObject>; + /** Deep-link into that policy's setup wizard. */ + onSetup: (key: string) => void; +} + +/** Centre column: the standing-policy catalogue — the particle "waist". */ +export function FlowPolicies({ + policies, + activeCount, + coreRef, + laneRefs, + onSetup, +}: FlowPoliciesProps) { + const { t } = useTranslation(); + return ( +
+
+ {t("portal.processorFlow.policies.heading")} + + {t("portal.processorFlow.policies.activeCount", { n: activeCount })} + +
+ {policies.map((policy) => ( +
+
{ + if (el) laneRefs.current[policy.key] = el; + else delete laneRefs.current[policy.key]; + }} + > + + {policyCategoryIcon(policy.key, { fontSize: "1.125rem" })} + + + {t(policy.labelKey)} + + {policy.state === "active" ? ( + + {t("portal.processorFlow.policies.count", { + n: policy.runs24h, + })} + + ) : policy.state === "off" ? ( + + ) : ( + + {t("portal.processorFlow.policies.soon")} + + )} +
+
+ ))} +
+ ); +} diff --git a/frontend/editor/src/portal/components/processor-flow/FlowSankey.tsx b/frontend/editor/src/portal/components/processor-flow/FlowSankey.tsx new file mode 100644 index 0000000000..1d90705c2c --- /dev/null +++ b/frontend/editor/src/portal/components/processor-flow/FlowSankey.tsx @@ -0,0 +1,237 @@ +import { type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { EmptyState } from "@app/ui"; +import type { + FlowOutcome, + FlowOutcomeKey, + FlowPolicy, + FlowSource, +} from "@portal/api/processorFlow"; +import { + EDITOR_TYPE, + OUTCOME_FILL, +} from "@portal/components/processor-flow/flowTypes"; + +interface FlowSankeyProps { + sources: FlowSource[]; + outcomes: FlowOutcome[]; + policies: FlowPolicy[]; +} + +/** + * Sankey lens: sources → policies waist → outcomes, ribbon width ∝ 24h volume. + * The waist splits into one segment per active policy. Shows a friendly empty + * state when nothing has flowed yet. + */ +export function FlowSankey({ sources, outcomes, policies }: FlowSankeyProps) { + const { t } = useTranslation(); + const activePolicies = policies.filter((p) => p.state === "active"); + const activeCount = activePolicies.length; + + const flows = sources.filter((s) => s.docs24h > 0); + const srcSum = flows.reduce((sum, s) => sum + s.docs24h, 0); + if (!srcSum) { + return ( +
+ +
+ ); + } + + const SW = 720; + const SH = 220; + const padY = 22; + const xL = 180; + const xR = 560; + const xM = (xL + xR) / 2; + const barW = 9; + const midW = 11; + const gap = 12; + const H = SH - padY * 2; + + const k = (H - (flows.length - 1) * gap) / srcSum; + const lt = flows.map((s) => Math.max(3, s.docs24h * k)); + const midH = lt.reduce((a, b) => a + b, 0); + const y0L = padY + (H - (midH + (flows.length - 1) * gap)) / 2; + const midY = padY + (H - midH) / 2; + + const outSum = outcomes.reduce((a, o) => a + o.count24h, 0); + const rawRt = outcomes.map((o) => + outSum > 0 + ? Math.max(3, midH * (o.count24h / outSum)) + : midH / outcomes.length, + ); + const rtSum = rawRt.reduce((a, b) => a + b, 0); + const rt = rawRt.map((v) => (v * midH) / rtSum); + const y0R = padY + (H - (midH + (outcomes.length - 1) * gap)) / 2; + + const outFill = (key: FlowOutcomeKey) => OUTCOME_FILL[key]; + const srcFill = "var(--color-blue)"; + const waistFill = "var(--color-text-4)"; + + const ribbon = ( + x0: number, + t0: number, + b0: number, + x1: number, + t1: number, + b1: number, + fill: string, + key: string, + ) => { + const mx = (x0 + x1) / 2; + return ( + + ); + }; + + const wires: ReactNode[] = []; + const bars: ReactNode[] = []; + const texts: ReactNode[] = []; + + // Left stage: sources → waist. + let accL = y0L; + let accM = midY; + flows.forEach((s, i) => { + wires.push( + ribbon( + xL + barW, + accL, + accL + lt[i], + xM, + accM, + accM + lt[i], + srcFill, + "wl" + i, + ), + ); + bars.push( + , + ); + const label = + s.type === EDITOR_TYPE + ? t("portal.processorFlow.sources.editor") + : s.name; + texts.push( + + {label} · {s.docs24h} + , + ); + accL += lt[i] + gap; + accM += lt[i]; + }); + + // Waist: one segment per active policy (sized by its 24h runs) so the centre + // reads as distinct policies rather than a single bar. + const segGap = 4; + const nSeg = Math.max(activePolicies.length, 1); + const segAvail = midH - (nSeg - 1) * segGap; + const polSum = activePolicies.reduce((a, p) => a + p.runs24h, 0); + const segPolicies = activePolicies.length ? activePolicies : [null]; + let segY = midY; + segPolicies.forEach((p, i) => { + const wgt = p && polSum > 0 ? p.runs24h / polSum : 1 / nSeg; + const h = Math.max(2, segAvail * wgt); + bars.push( + , + ); + segY += h + segGap; + }); + texts.push( + + {t("portal.processorFlow.sankey.waist", { n: activeCount })} + , + ); + + // Right stage: waist → outcomes. + let accWaist = midY; + let accR = y0R; + outcomes.forEach((o, j) => { + wires.push( + ribbon( + xM + midW, + accWaist, + accWaist + rt[j], + xR, + accR, + accR + rt[j], + outFill(o.key), + "wr" + j, + ), + ); + bars.push( + , + ); + texts.push( + + {t(o.labelKey)} · {o.count24h} + , + ); + accWaist += rt[j]; + accR += rt[j] + gap; + }); + + return ( +
+ + {wires} + {bars} + {texts} + +
+ ); +} diff --git a/frontend/editor/src/portal/components/processor-flow/FlowSources.tsx b/frontend/editor/src/portal/components/processor-flow/FlowSources.tsx new file mode 100644 index 0000000000..927e74304d --- /dev/null +++ b/frontend/editor/src/portal/components/processor-flow/FlowSources.tsx @@ -0,0 +1,94 @@ +import { type RefObject } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import type { + FlowComingSoonSource, + FlowSource, +} from "@portal/api/processorFlow"; +import { + EDITOR_TYPE, + ICON_SIZE, +} from "@portal/components/processor-flow/flowTypes"; +import { SourceIcon } from "@portal/components/processor-flow/FlowIcons"; + +interface FlowSourcesProps { + sources: FlowSource[]; + comingSoonSources: FlowComingSoonSource[]; + /** One ref slot per live source, in order, for geometry measurement. */ + srcRefs: RefObject<(HTMLElement | null)[]>; + onOpen: () => void; +} + +/** Left column: live source cards (measured) + coming-soon connect cards. */ +export function FlowSources({ + sources, + comingSoonSources, + srcRefs, + onOpen, +}: FlowSourcesProps) { + const { t } = useTranslation(); + return ( +
+ + {t("portal.processorFlow.sources.heading")} + + {sources.map((source, i) => ( + + ))} + {comingSoonSources.map((cs) => ( + + ))} +
+ ); +} diff --git a/frontend/editor/src/portal/components/processor-flow/flowTypes.ts b/frontend/editor/src/portal/components/processor-flow/flowTypes.ts new file mode 100644 index 0000000000..217d35760f --- /dev/null +++ b/frontend/editor/src/portal/components/processor-flow/flowTypes.ts @@ -0,0 +1,103 @@ +import type { FlowOutcomeKey } from "@portal/api/processorFlow"; + +/** Which lens the visualiser is showing. */ +export type Lens = "flow" | "sankey"; + +/** + * DEV ONLY: force the flow animation on even when nothing is set up / no + * activity has taken place, using synthetic rates. Off by design — an idle + * machine shows no animated dots; flip to true only to preview the motion + * while iterating on an empty workspace. + */ +export const DEV_KEEP_FLOWING = false; + +export const EDITOR_TYPE = "editor"; + +/** Emission tuning: particles/sec for a source ≈ rate / 86400 × SPEED. */ +export const SPEED = 300; +/** Synthetic per-source rate used only while DEV_KEEP_FLOWING forces the flow. */ +export const DEV_SYNTH_RATE = 320; +/** Hard cap on live particles (matches the reference). */ +export const MAX_PARTICLES = 36; +/** No two dots leave the same source within this window (ms). */ +export const MIN_EMIT_GAP = 200; + +export const ICON_SIZE = "1.125rem"; + +/** SVG `fill` (a CSS property, so var() resolves per-theme) for each outcome. */ +export const OUTCOME_FILL: Record = { + success: "var(--color-green)", + failed: "var(--color-red)", +}; + +/* ── Measured geometry ──────────────────────────────────────────────────── */ + +export interface Rect { + l: number; + r: number; + t: number; + b: number; + cy: number; +} + +export interface Lane { + key: string; + cy: number; + el: HTMLElement; +} + +export interface Geo { + w: number; + h: number; + srcs: (Rect | undefined)[]; + outs: (Rect | undefined)[]; + core: Rect | null; + lanes: Lane[]; +} + +export interface Point { + x: number; + y: number; +} + +export interface Particle { + el: SVGCircleElement; + src: number; + out: number; + lane: string | null; + phase: 0 | 1 | 2; + t: number; + d0: number; + d1: number; + d2: number; + pulsed: boolean; +} + +/** Cubic bézier point at t. */ +export function cbez(a: Point, b: Point, c: Point, d: Point, t: number): Point { + const m = 1 - t; + return { + x: + m * m * m * a.x + + 3 * m * m * t * b.x + + 3 * m * t * t * c.x + + t * t * t * d.x, + y: + m * m * m * a.y + + 3 * m * m * t * b.y + + 3 * m * t * t * c.y + + t * t * t * d.y, + }; +} + +/** Smoothstep easing used for the in-card lane glide. */ +export function smooth(t: number): number { + return t * t * (3 - 2 * t); +} + +/** Where each source's wire enters the core (spread across its height). */ +export function coreEntryY(g: Geo, i: number): number { + if (!g.core) return 0; + const n = Math.max(g.srcs.length, 2); + return g.core.t + 34 + (g.core.b - g.core.t - 68) * (i / (n - 1)); +} diff --git a/frontend/editor/src/portal/components/processor-flow/useFlowGeometry.tsx b/frontend/editor/src/portal/components/processor-flow/useFlowGeometry.tsx new file mode 100644 index 0000000000..1e43ebc161 --- /dev/null +++ b/frontend/editor/src/portal/components/processor-flow/useFlowGeometry.tsx @@ -0,0 +1,123 @@ +import { + useEffect, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { + coreEntryY, + type Geo, + type Rect, +} from "@portal/components/processor-flow/flowTypes"; + +/** + * Owns the measured-geometry seam for the flow visualiser: refs for the source, + * outcome and core cards (plus the per-policy lane lines), a `measure()` that + * projects their edges into a wrapper-relative {@link Geo}, and the SVG wires + * drawn between them. The particle loop reads the same `geoRef` live. + * + * Callers spread the returned refs onto the cards and render `wires` inside the + * underlay ``; geometry re-measures on every layout + on resize, and the + * wires re-render only when the measured signature actually changes. + */ +export function useFlowGeometry() { + const wrapRef = useRef(null); + const srcRefs = useRef<(HTMLElement | null)[]>([]); + const outRefs = useRef<(HTMLElement | null)[]>([]); + const coreRef = useRef(null); + const laneRefs = useRef>({}); + const geoRef = useRef(null); + const geoSigRef = useRef(""); + const [, setGeoTick] = useState(0); + + const measure = () => { + const w = wrapRef.current; + if (!w) return; + const wr = w.getBoundingClientRect(); + if (!wr.width) return; + const rel = (r: DOMRect): Rect => ({ + l: r.left - wr.left, + r: r.right - wr.left, + t: r.top - wr.top, + b: r.bottom - wr.top, + cy: r.top - wr.top + r.height / 2, + }); + const g: Geo = { + w: wr.width, + h: wr.height, + srcs: [], + outs: [], + core: null, + lanes: [], + }; + srcRefs.current.forEach((el, i) => { + if (el) g.srcs[i] = rel(el.getBoundingClientRect()); + }); + outRefs.current.forEach((el, j) => { + if (el) g.outs[j] = rel(el.getBoundingClientRect()); + }); + if (coreRef.current) g.core = rel(coreRef.current.getBoundingClientRect()); + Object.entries(laneRefs.current).forEach(([key, el]) => { + if (el && el.isConnected) + g.lanes.push({ key, cy: rel(el.getBoundingClientRect()).cy, el }); + }); + geoRef.current = g; + + let cySum = 0; + g.srcs.forEach((s) => s && (cySum += s.cy)); + g.outs.forEach((o) => o && (cySum += o.cy)); + const sig = [ + Math.round(g.w), + Math.round(g.h), + g.srcs.length, + g.outs.length, + g.core ? Math.round(g.core.t) + ":" + Math.round(g.core.b) : 0, + Math.round(cySum), + ].join(":"); + if (sig !== geoSigRef.current) { + geoSigRef.current = sig; + setGeoTick((n) => n + 1); + } + }; + + useLayoutEffect(measure); + + useEffect(() => { + const onResize = () => measure(); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, []); + + // Wires (SVG underlay); recomputed whenever the geometry signature changes. + const g = geoRef.current; + let wires: ReactNode = null; + if (g && g.core) { + const core = g.core; + const paths: ReactNode[] = []; + g.srcs.forEach((s, i) => { + if (!s) return; + const ty = coreEntryY(g, i); + paths.push( + , + ); + }); + g.outs.forEach((o, j) => { + if (!o) return; + paths.push( + , + ); + }); + wires = paths; + } + + return { wrapRef, srcRefs, outRefs, coreRef, laneRefs, geoRef, wires }; +} diff --git a/frontend/editor/src/portal/components/processor-flow/useFlowParticles.ts b/frontend/editor/src/portal/components/processor-flow/useFlowParticles.ts new file mode 100644 index 0000000000..0d19ab5295 --- /dev/null +++ b/frontend/editor/src/portal/components/processor-flow/useFlowParticles.ts @@ -0,0 +1,261 @@ +import { useEffect, useRef, type RefObject } from "react"; +import type { FlowOutcomeKey } from "@portal/api/processorFlow"; +import { + cbez, + coreEntryY, + smooth, + MAX_PARTICLES, + MIN_EMIT_GAP, + OUTCOME_FILL, + SPEED, + type Geo, + type Lens, + type Particle, + type Point, +} from "@portal/components/processor-flow/flowTypes"; + +interface FlowParticlesOptions { + geoRef: RefObject; + animate: boolean; + lens: Lens; + /** Per-source emission rate (docs/24h, or synthetic while dev-forcing). */ + rates: number[]; + /** Outcome share for the weighted round-robin destination picker. */ + weights: number[]; + /** Policy lane keys a dot may thread through the core. */ + laneKeys: string[]; + /** Outcome keys, index-aligned with `weights`, for recolouring on arrival. */ + outcomeKeys: FlowOutcomeKey[]; +} + +/** + * Drives the rAF particle loop: emits dots per source on a jittered schedule + * (min-gap floored), routes each to an outcome via a weighted round-robin so + * the split matches the counts, threads it through a policy lane (blinking that + * row's LED), and recolours it to the outcome on arrival. Reads geometry live + * from `geoRef`, so it tracks card movement without restarting. + * + * Returns the `` ref the caller mounts inside the particle overlay ``. + * The loop only runs on the flow lens, when `animate` is set, and outside + * reduced-motion; browsers pause rAF for hidden tabs (desirable). + */ +export function useFlowParticles({ + geoRef, + animate, + lens, + rates, + weights, + laneKeys, + outcomeKeys, +}: FlowParticlesOptions): RefObject { + const pGroupRef = useRef(null); + + // Restart the loop only when the meaningful inputs change. + const flowSig = [ + animate, + lens, + rates.join(","), + laneKeys.join(","), + weights.map((w) => w.toFixed(3)).join(","), + outcomeKeys.join(","), + ].join("|"); + + useEffect(() => { + if (!animate || lens !== "flow") return; + const reduced = + typeof window !== "undefined" && + window.matchMedia && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (reduced) return; + const pg = pGroupRef.current; + if (!pg) return; + const NS = "http://www.w3.org/2000/svg"; + + const particles: Particle[] = []; + let last = performance.now(); + let raf = 0; + const glowTimers: Record> = {}; + + // Per-source emission schedule. Mean interval keeps each source's share of + // the flow proportional to its rate; the scheduled model (vs. a steady + // accumulator) is what lets us jitter departures and floor the gap. + const meanInterval = rates.map((r) => { + const perSec = (r / 86400) * SPEED; + return perSec > 0 ? 1000 / perSec : Infinity; + }); + // Stagger the first emission so sources don't all fire together at t=0. + const nextEmit = meanInterval.map((mi) => + Number.isFinite(mi) ? last + Math.random() * mi : Infinity, + ); + // Random departure within [0.5×, 1.5×] the mean, but never closer than the + // minimum gap — a random flow with no two dots out at once per source. + const scheduleNext = (i: number, now: number): number => + now + Math.max(MIN_EMIT_GAP, meanInterval[i] * (0.5 + Math.random())); + + // Weighted round-robin so the outcome split visibly matches the counts + // (e.g. 3 failed / 30 delivered → ~1 in 11 dots to Failed), interleaved + // rather than clustered like independent random draws. + const outAcc = weights.map(() => 0); + const pickOut = (): number => { + if (!weights.length) return 0; + for (let j = 0; j < weights.length; j++) outAcc[j] += weights[j]; + let best = 0; + for (let j = 1; j < weights.length; j++) { + if (outAcc[j] > outAcc[best]) best = j; + } + outAcc[best] -= 1; + return best; + }; + const pickLane = (): string | null => { + if (!laneKeys.length) return null; + return laneKeys[Math.floor(Math.random() * laneKeys.length)]; + }; + const laneY = (g: Geo, key: string | null): number | null => { + if (!key) return null; + const l = g.lanes.find((x) => x.key === key); + return l ? l.cy : null; + }; + // Blink the row's leading LED (its icon) for 150ms as a particle threads it. + const pulseLane = (g: Geo, key: string | null) => { + if (!key) return; + const lane = g.lanes.find((x) => x.key === key); + const led = lane?.el.firstElementChild; + if (!led || !led.classList.contains("portal-pf__policy-icon")) return; + led.classList.add("is-pulse"); + if (glowTimers[key]) clearTimeout(glowTimers[key]); + glowTimers[key] = setTimeout(() => led.classList.remove("is-pulse"), 150); + }; + + const frame = (now: number) => { + const g = geoRef.current; + const dt = Math.min(now - last, 200); + last = now; + if (g && g.core) { + // At most one dot per source per frame, once its scheduled (jittered) + // departure time is reached — guarantees the per-source minimum gap. + for (let i = 0; i < meanInterval.length; i++) { + if (!Number.isFinite(meanInterval[i]) || !g.srcs[i]) continue; + if (now >= nextEmit[i] && particles.length < MAX_PARTICLES) { + const c = document.createElementNS( + NS, + "circle", + ) as SVGCircleElement; + c.setAttribute("r", "2.5"); + c.setAttribute("opacity", "0.75"); + c.style.fill = "var(--color-blue)"; + pg.appendChild(c); + particles.push({ + el: c, + src: i, + out: pickOut(), + lane: pickLane(), + phase: 0, + t: 0, + d0: 900 + Math.random() * 300, + d1: 760, + d2: 780 + Math.random() * 200, + pulsed: false, + }); + nextEmit[i] = scheduleNext(i, now); + } + } + + for (let k = particles.length - 1; k >= 0; k--) { + const p = particles[k]; + p.t += dt; + const s = g.srcs[p.src]; + if (!s) { + p.el.remove(); + particles.splice(k, 1); + continue; + } + let pos: Point; + if (p.phase === 0) { + const ey = coreEntryY(g, p.src); + const f0 = Math.min(1, p.t / p.d0); + pos = cbez( + { x: s.r, y: s.cy }, + { x: s.r + 44, y: s.cy }, + { x: g.core.l - 44, y: ey }, + { x: g.core.l, y: ey }, + f0, + ); + if (f0 >= 1) { + p.phase = 1; + p.t = 0; + p.pulsed = false; + p.el.setAttribute("r", "2"); + p.el.setAttribute("opacity", "0.45"); + } + } else if (p.phase === 1) { + const o1 = g.outs[p.out]; + if (!o1) { + p.el.remove(); + particles.splice(k, 1); + continue; + } + const f1 = Math.min(1, p.t / p.d1); + const entY = coreEntryY(g, p.src); + const exitY = o1.cy; + const ly1 = laneY(g, p.lane); + let yy: number; + if (ly1 == null) { + yy = entY + (exitY - entY) * f1; + } else if (f1 < 0.25) { + yy = entY + (ly1 - entY) * smooth(f1 / 0.25); + } else if (f1 < 0.75) { + yy = ly1; + if (!p.pulsed) { + p.pulsed = true; + pulseLane(g, p.lane); + } + } else { + yy = ly1 + (exitY - ly1) * smooth((f1 - 0.75) / 0.25); + } + pos = { x: g.core.l + (g.core.r - g.core.l) * f1, y: yy }; + if (f1 >= 1) { + p.phase = 2; + p.t = 0; + p.el.style.fill = + OUTCOME_FILL[outcomeKeys[p.out]] ?? "var(--color-blue)"; + p.el.setAttribute("r", "2.5"); + p.el.setAttribute("opacity", "0.75"); + } + } else { + const o2 = g.outs[p.out]; + if (!o2) { + p.el.remove(); + particles.splice(k, 1); + continue; + } + const f2 = Math.min(1, p.t / p.d2); + pos = cbez( + { x: g.core.r, y: o2.cy }, + { x: g.core.r + 44, y: o2.cy }, + { x: o2.l - 44, y: o2.cy }, + { x: o2.l, y: o2.cy }, + f2, + ); + if (f2 >= 1) { + p.el.remove(); + particles.splice(k, 1); + continue; + } + } + p.el.setAttribute("cx", String(pos.x)); + p.el.setAttribute("cy", String(pos.y)); + } + } + raf = requestAnimationFrame(frame); + }; + raf = requestAnimationFrame(frame); + + return () => { + cancelAnimationFrame(raf); + Object.values(glowTimers).forEach(clearTimeout); + while (pg.firstChild) pg.removeChild(pg.firstChild); + }; + }, [flowSig, geoRef]); + + return pGroupRef; +} diff --git a/frontend/editor/src/portal/mocks/policies.ts b/frontend/editor/src/portal/mocks/policies.ts index 64a9b2ed19..1a3cfa2259 100644 --- a/frontend/editor/src/portal/mocks/policies.ts +++ b/frontend/editor/src/portal/mocks/policies.ts @@ -31,6 +31,10 @@ const SECURITY_STEPS: WirePipelineStep[] = [ }, ]; +const CLASSIFICATION_STEPS: WirePipelineStep[] = [ + { operation: "/api/v1/ai/tools/classify-and-label", parameters: {} }, +]; + export function seedPolicies(): WirePolicy[] { return [ { @@ -57,57 +61,80 @@ export function seedPolicies(): WirePolicy[] { }, }, }, + { + id: "pol_classification_default", + name: "Classification Policy", + owner: "data-eng@acme.com", + enabled: true, + trigger: null, + steps: CLASSIFICATION_STEPS, + output: { + type: "inline", + options: { + runOn: "upload", + mode: "new_version", + name: "", + position: "suffix", + maxRetries: 3, + retryDelayMinutes: 5, + categoryId: "classification", + sources: ["src-contracts"], + scopeTypes: [], + reviewerEmail: "data-eng@acme.com", + fieldValues: {}, + }, + }, + }, ]; } const NOW = Date.now(); const M = 60000; -const H = 3600000; const D = 86400000; -/** Seed `PolicyRunView` records that drive the activity feed + stats. */ +/** Seed `PolicyRunView` records that drive the activity feed + stats. + * 40 delivered + 3 failed within the trailing 24h so the home visualiser shows + * a lively flow; a tail of older completed runs keeps the lifetime stats real. + * Runs are split across the two active policies so the Sankey waist divides. */ export function seedPolicyRuns(): PolicyRunView[] { - return [ - { - runId: "run_001", - policyId: "pol_security_default", - status: "COMPLETED", - currentStep: 2, - stepCount: 2, - error: null, - outputs: [{ fileId: "f1", fileName: "Q2-vendor-agreement.pdf" }], - createdAt: NOW - 12 * M, - }, - { - runId: "run_002", - policyId: "pol_security_default", - status: "FAILED", - currentStep: 1, - stepCount: 2, - error: "Low-confidence match — routed for review", - outputs: [{ fileId: "f2", fileName: "patient-intake-0481.pdf" }], - createdAt: NOW - 1 * H, - }, - { - runId: "run_003", - policyId: "pol_security_default", - status: "RUNNING", - currentStep: 1, - stepCount: 2, - error: null, - outputs: [{ fileId: "f3", fileName: "invoice-7782.pdf" }], - createdAt: NOW - 2 * M, - }, - // Older completed runs for stats - ...Array.from({ length: 4818 }, (_, i) => ({ - runId: `run_old_${i}`, - policyId: "pol_security_default", - status: "COMPLETED" as const, - currentStep: 2, - stepCount: 2, - error: null, - outputs: [] as { fileId: string; fileName: string }[], - createdAt: NOW - (34 * D + i * 10 * M), - })), - ]; + // Split the throughput across the two active policies (security / classification) + // so both show a 24h count and the Sankey waist splits into two segments. + const policyFor = (i: number, total: number) => + i < Math.round(total * 0.6) + ? "pol_security_default" + : "pol_classification_default"; + // 40 successful runs spread across the last ~13h. + const delivered = Array.from({ length: 40 }, (_, i) => ({ + runId: `run_ok_${i}`, + policyId: policyFor(i, 40), + status: "COMPLETED" as const, + currentStep: 2, + stepCount: 2, + error: null, + outputs: [{ fileId: `f${i}`, fileName: `document-${i + 1}.pdf` }], + createdAt: NOW - i * 20 * M, + })); + // 3 failures within the last few hours. + const failed = Array.from({ length: 3 }, (_, i) => ({ + runId: `run_fail_${i}`, + policyId: policyFor(i, 3), + status: "FAILED" as const, + currentStep: 1, + stepCount: 2, + error: "Low-confidence match — routed for review", + outputs: [{ fileId: `ff${i}`, fileName: `flagged-${i + 1}.pdf` }], + createdAt: NOW - (i + 1) * 90 * M, + })); + // Older completed runs (>24h) for lifetime stats — excluded from 24h counts. + const older = Array.from({ length: 4800 }, (_, i) => ({ + runId: `run_old_${i}`, + policyId: "pol_security_default", + status: "COMPLETED" as const, + currentStep: 2, + stepCount: 2, + error: null, + outputs: [] as { fileId: string; fileName: string }[], + createdAt: NOW - (34 * D + i * 10 * M), + })); + return [...delivered, ...failed, ...older]; } diff --git a/frontend/editor/src/portal/views/Home.tsx b/frontend/editor/src/portal/views/Home.tsx index 2f8f812581..e127fe41ad 100644 --- a/frontend/editor/src/portal/views/Home.tsx +++ b/frontend/editor/src/portal/views/Home.tsx @@ -4,6 +4,7 @@ import { useTier } from "@portal/contexts/TierContext"; import { useView, type ViewId } from "@portal/contexts/ViewContext"; import { HomeHero } from "@portal/components/HomeHero"; import { HomeGreeting } from "@portal/components/HomeGreeting"; +import { ProcessorFlow } from "@portal/components/ProcessorFlow"; import { RecentActivity } from "@portal/components/RecentActivity"; import { ProcessingStatusStrip } from "@portal/components/ProcessingStatusStrip"; import { PolicySummary } from "@portal/components/PolicySummary"; @@ -142,6 +143,7 @@ export function Home() { {/* Per-tier hero. Its footer is the deal-status hero while a procurement deal is underway (a bolt-on to any tier), otherwise the setup checklist. */} + {/* One unified layout across tiers: real processed-PDF volume, real audit activity, quick actions, and the standing-policy summary. */} diff --git a/frontend/editor/src/portal/views/Infrastructure.tsx b/frontend/editor/src/portal/views/Infrastructure.tsx index bc0920382c..7d720f6895 100644 --- a/frontend/editor/src/portal/views/Infrastructure.tsx +++ b/frontend/editor/src/portal/views/Infrastructure.tsx @@ -1,4 +1,5 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Button, Tabs, type TabItem } from "@app/ui"; import { useView } from "@portal/contexts/ViewContext"; @@ -18,10 +19,33 @@ type InfraTab = | "storage" | "audit"; +const INFRA_TABS: InfraTab[] = [ + "deployments", + "api-keys", + "security", + "models", + "storage", + "audit", +]; + export function Infrastructure() { const { t } = useTranslation(); const [tab, setTab] = useState("deployments"); const { setActiveView } = useView(); + const [searchParams, setSearchParams] = useSearchParams(); + + // Deep-link (?tab=) from elsewhere (e.g. the home visualiser's outcome + // cards → audit log): open that tab, then drop the param. + useEffect(() => { + const requested = searchParams.get("tab"); + if (!requested) return; + if ((INFRA_TABS as string[]).includes(requested)) { + setTab(requested as InfraTab); + } + const next = new URLSearchParams(searchParams); + next.delete("tab"); + setSearchParams(next, { replace: true }); + }, [searchParams, setSearchParams]); const tabs: TabItem[] = [ { key: "deployments", label: t("portal.infrastructure.tabs.deployments") }, diff --git a/frontend/editor/src/portal/views/Policies.tsx b/frontend/editor/src/portal/views/Policies.tsx index a4ffcf1067..79440f96e7 100644 --- a/frontend/editor/src/portal/views/Policies.tsx +++ b/frontend/editor/src/portal/views/Policies.tsx @@ -1,4 +1,5 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Banner, Button, Skeleton } from "@app/ui"; import { errorMessage } from "@portal/api/http"; @@ -34,6 +35,20 @@ export function Policies() { const [wizard, setWizard] = useState(null); const [busy, setBusy] = useState(false); const [pageError, setPageError] = useState(null); + const [searchParams, setSearchParams] = useSearchParams(); + + useEffect(() => { + const setupId = searchParams.get("setup"); + if (!setupId || !data) return; + const entry = data.catalogue.find((e) => e.category.id === setupId); + if (entry && !entry.category.comingSoon) { + if (entry.policy) setDetail(entry); + else setWizard(entry); + } + const next = new URLSearchParams(searchParams); + next.delete("setup"); + setSearchParams(next, { replace: true }); + }, [searchParams, data, setSearchParams]); const { enabled: aiEngineEnabled, loading: aiEngineLoading } = useAiEngineEnabled(); diff --git a/frontend/editor/src/portal/views/Sources.css b/frontend/editor/src/portal/views/Sources.css index b0966c3151..90d30c4175 100644 --- a/frontend/editor/src/portal/views/Sources.css +++ b/frontend/editor/src/portal/views/Sources.css @@ -69,31 +69,33 @@ flex-shrink: 0; border-radius: var(--radius-md); font-size: 0.875rem; + color: var(--dot-c, var(--color-text-3)); + background: color-mix( + in srgb, + var(--dot-c, var(--color-text-3)) 14%, + transparent + ); } +/* Neutral keeps a plain muted surface rather than an accent tint. */ .portal-sources__type-dot--neutral { - background: var(--color-bg-subtle); + background: var(--color-bg-muted); color: var(--color-text-3); } .portal-sources__type-dot--default { - background: var(--color-blue-light); - color: var(--color-blue); + --dot-c: var(--color-blue-dark); } .portal-sources__type-dot--premium { - background: var(--color-purple-light); - color: var(--color-purple); + --dot-c: var(--color-purple-dark); } .portal-sources__type-dot--success { - background: var(--color-green-light); - color: var(--color-green-dark); + --dot-c: var(--color-green-dark); } .portal-sources__type-dot--warning { - background: var(--color-amber-light); - color: var(--color-amber-dark); + --dot-c: var(--color-amber-dark); } .portal-sources__type-dot--danger { - background: var(--color-red-light); - color: var(--color-red); + --dot-c: var(--color-red-dark); } .portal-sources__muted {