The flow chart for policies running (#7014)

This commit is contained in:
EthanHealy01
2026-07-16 15:32:08 +00:00
committed by GitHub
parent 275d70242e
commit 6b2ab5a743
20 changed files with 2006 additions and 76 deletions
+21 -3
View File
@@ -43,14 +43,32 @@ function parseEnTranslation(): Record<string, unknown> {
}
}
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.
@@ -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"
+20 -15
View File
@@ -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);
}
@@ -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<ProcessorFlow> {
const [sourcesResp, wirePolicies, runs] = await Promise.all([
fetchSources(),
apiClient.local.json<WirePolicy[]>("/api/v1/policies"),
apiClient.local
.json<PolicyRunView[]>("/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),
};
}
@@ -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;
}
}
@@ -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<typeof ProcessorFlow> = {
title: "Portal/Components/ProcessorFlow",
component: ProcessorFlow,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof ProcessorFlow>;
/** 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([])),
],
},
},
};
@@ -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<ProcessorFlowModel>(
() => fetchProcessorFlow(),
[],
);
const [lens, setLens] = useState<Lens>("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 (
<Card padding="loose" className="portal-pf">
<header className="portal-pf__head">
<div className="portal-pf__head-text">
<span
className={
"portal-pf__live" + (animate ? " portal-pf__live--on" : "")
}
aria-hidden
/>
<h2 className="portal-pf__title">
{t("portal.processorFlow.title")}
</h2>
<span className="portal-pf__connected">{statsLabel}</span>
</div>
<div className="portal-pf__head-actions">
<StatusBadge tone={animate ? "success" : "neutral"} size="sm">
{t("portal.processorFlow.liveBadge")}
</StatusBadge>
<SegmentedControl<Lens>
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" },
]}
/>
</div>
</header>
{isLoading ? (
<div className="portal-pf__loading" aria-hidden>
<Skeleton height="9rem" />
</div>
) : lens === "sankey" ? (
<FlowSankey sources={sources} outcomes={outcomes} policies={policies} />
) : (
<div className="portal-pf__stage" ref={wrapRef}>
<svg className="portal-pf__wires" aria-hidden>
{wires}
</svg>
<div className="portal-pf__cols">
<FlowSources
sources={sources}
comingSoonSources={comingSoonSources}
srcRefs={srcRefs}
onOpen={() => setActiveView("sources")}
/>
<FlowPolicies
policies={policies}
activeCount={activeCount}
coreRef={coreRef}
laneRefs={laneRefs}
onSetup={openPolicySetup}
/>
<FlowOutcomes
outcomes={outcomes}
outRefs={outRefs}
onOpen={openAuditLog}
/>
</div>
<svg className="portal-pf__particles" aria-hidden>
<g ref={pGroupRef} />
</svg>
</div>
)}
<p className="portal-pf__foot">{t("portal.processorFlow.footnote")}</p>
</Card>
);
}
@@ -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 <LocalIcon icon="edit-document" width={ICON_SIZE} />;
case "s3":
return <LocalIcon icon="cloud" width={ICON_SIZE} />;
case "folder":
return <LocalIcon icon="folder" width={ICON_SIZE} />;
default:
return <LocalIcon icon="database" width={ICON_SIZE} />;
}
}
/** 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 <LocalIcon icon="check-circle" width={ICON_SIZE} />;
return <LocalIcon icon="cancel" width={ICON_SIZE} />;
}
@@ -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 (
<section
className="portal-pf__col portal-pf__col--outcomes"
aria-label={t("portal.processorFlow.outcomes.heading")}
>
<span className="portal-pf__col-head">
{t("portal.processorFlow.outcomes.heading")}
</span>
{outcomes.map((outcome, j) => (
<Button
key={outcome.key}
variant="quiet"
justify="start"
fullWidth
px="sm"
py="sm"
className={
"portal-pf__node portal-pf__node--outcome portal-pf__node--" +
outcome.key
}
onClick={onOpen}
ref={(el: HTMLButtonElement | null) => {
outRefs.current[j] = el;
}}
leftSection={
<span className="portal-pf__node-icon" aria-hidden>
<OutcomeIcon outcome={outcome.key} />
</span>
}
>
<span className="portal-pf__node-text">
<strong>{t(outcome.labelKey)}</strong>
<span>
{t("portal.processorFlow.outcomes.count", {
n: outcome.count24h,
})}
</span>
</span>
</Button>
))}
</section>
);
}
@@ -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<HTMLDivElement | null>;
/** Per-policy lane-line refs, keyed by policy id, for particle threading. */
laneRefs: RefObject<Record<string, HTMLElement>>;
/** 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 (
<div className="portal-pf__policies" ref={coreRef}>
<div className="portal-pf__policies-head">
<span>{t("portal.processorFlow.policies.heading")}</span>
<span className="portal-pf__policies-active">
{t("portal.processorFlow.policies.activeCount", { n: activeCount })}
</span>
</div>
{policies.map((policy) => (
<div
key={policy.key}
className={"portal-pf__policy portal-pf__policy--" + policy.state}
>
<div
className="portal-pf__policy-line"
ref={(el: HTMLDivElement | null) => {
if (el) laneRefs.current[policy.key] = el;
else delete laneRefs.current[policy.key];
}}
>
<span className="portal-pf__policy-icon" aria-hidden>
{policyCategoryIcon(policy.key, { fontSize: "1.125rem" })}
</span>
<span className="portal-pf__policy-label">
{t(policy.labelKey)}
</span>
{policy.state === "active" ? (
<span className="portal-pf__policy-count">
{t("portal.processorFlow.policies.count", {
n: policy.runs24h,
})}
</span>
) : policy.state === "off" ? (
<Button
size="sm"
py="xs"
variant="primary"
className="portal-pf__setup"
onClick={() => onSetup(policy.key)}
>
{t("portal.processorFlow.policies.setUp")}
</Button>
) : (
<span className="portal-pf__policy-soon">
{t("portal.processorFlow.policies.soon")}
</span>
)}
</div>
</div>
))}
</div>
);
}
@@ -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 (
<div className="portal-pf__sankey-empty">
<EmptyState
size="compact"
title={t("portal.processorFlow.sankey.empty.title")}
description={t("portal.processorFlow.sankey.empty.description")}
/>
</div>
);
}
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 (
<path
key={key}
d={`M ${x0} ${t0} C ${mx} ${t0}, ${mx} ${t1}, ${x1} ${t1} L ${x1} ${b1} C ${mx} ${b1}, ${mx} ${b0}, ${x0} ${b0} Z`}
style={{ fill }}
opacity={0.3}
/>
);
};
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(
<rect
key={"bl" + i}
x={xL}
y={accL}
width={barW}
height={lt[i]}
rx={2}
style={{ fill: srcFill }}
opacity={0.9}
/>,
);
const label =
s.type === EDITOR_TYPE
? t("portal.processorFlow.sources.editor")
: s.name;
texts.push(
<text
key={"tl" + i}
x={xL - 10}
y={accL + lt[i] / 2 + 4}
textAnchor="end"
className="portal-pf__sankey-label"
>
{label} · {s.docs24h}
</text>,
);
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(
<rect
key={"waist" + i}
x={xM}
y={segY}
width={midW}
height={h}
rx={2}
style={{ fill: waistFill }}
opacity={0.9}
/>,
);
segY += h + segGap;
});
texts.push(
<text
key="waist-cap"
x={xM + midW / 2}
y={midY - 8}
textAnchor="middle"
className="portal-pf__sankey-caption"
>
{t("portal.processorFlow.sankey.waist", { n: activeCount })}
</text>,
);
// 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(
<rect
key={"br" + j}
x={xR}
y={accR}
width={barW}
height={rt[j]}
rx={2}
style={{ fill: outFill(o.key) }}
opacity={0.9}
/>,
);
texts.push(
<text
key={"tr" + j}
x={xR + barW + 10}
y={accR + rt[j] / 2 + 4}
textAnchor="start"
className="portal-pf__sankey-label"
>
{t(o.labelKey)} · {o.count24h}
</text>,
);
accWaist += rt[j];
accR += rt[j] + gap;
});
return (
<div className="portal-pf__sankey">
<svg viewBox={`0 0 ${SW} ${SH}`} width="100%" aria-hidden>
{wires}
{bars}
{texts}
</svg>
</div>
);
}
@@ -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 (
<section
className="portal-pf__col portal-pf__col--sources"
aria-label={t("portal.processorFlow.sources.heading")}
>
<span className="portal-pf__col-head">
{t("portal.processorFlow.sources.heading")}
</span>
{sources.map((source, i) => (
<Button
key={source.id}
variant="quiet"
justify="start"
fullWidth
px="sm"
py="sm"
className="portal-pf__node portal-pf__node--source"
onClick={onOpen}
ref={(el: HTMLButtonElement | null) => {
srcRefs.current[i] = el;
}}
leftSection={
<span className="portal-pf__node-icon" aria-hidden>
<SourceIcon type={source.type} />
</span>
}
>
<span className="portal-pf__node-text">
<strong>
{source.type === EDITOR_TYPE
? t("portal.processorFlow.sources.editor")
: source.name}
</strong>
<span>
{t("portal.processorFlow.sources.perDay", { n: source.docs24h })}
</span>
</span>
</Button>
))}
{comingSoonSources.map((cs) => (
<Button
key={cs.key}
variant="quiet"
justify="start"
fullWidth
px="sm"
py="sm"
className="portal-pf__node portal-pf__node--soon"
onClick={onOpen}
leftSection={
<span className="portal-pf__node-icon" aria-hidden>
<LocalIcon icon="add" width={ICON_SIZE} />
</span>
}
>
<span className="portal-pf__node-text">
<strong>{t(cs.labelKey)}</strong>
<span>{t("portal.processorFlow.sources.comingSoonTag")}</span>
</span>
</Button>
))}
</section>
);
}
@@ -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<FlowOutcomeKey, string> = {
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));
}
@@ -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 `<svg>`; 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<HTMLDivElement>(null);
const srcRefs = useRef<(HTMLElement | null)[]>([]);
const outRefs = useRef<(HTMLElement | null)[]>([]);
const coreRef = useRef<HTMLDivElement>(null);
const laneRefs = useRef<Record<string, HTMLElement>>({});
const geoRef = useRef<Geo | null>(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(
<path
key={"ws" + i}
className="portal-pf__wire-path"
d={`M ${s.r} ${s.cy} C ${s.r + 44} ${s.cy}, ${core.l - 44} ${ty}, ${core.l} ${ty}`}
/>,
);
});
g.outs.forEach((o, j) => {
if (!o) return;
paths.push(
<path
key={"wo" + j}
className="portal-pf__wire-path"
d={`M ${core.r} ${o.cy} C ${core.r + 44} ${o.cy}, ${o.l - 44} ${o.cy}, ${o.l} ${o.cy}`}
/>,
);
});
wires = paths;
}
return { wrapRef, srcRefs, outRefs, coreRef, laneRefs, geoRef, wires };
}
@@ -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<Geo | null>;
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 `<g>` ref the caller mounts inside the particle overlay `<svg>`.
* 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<SVGGElement | null> {
const pGroupRef = useRef<SVGGElement>(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<string, ReturnType<typeof setTimeout>> = {};
// 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;
}
+58 -31
View File
@@ -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,49 +61,72 @@ 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",
// 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: "f1", fileName: "Q2-vendor-agreement.pdf" }],
createdAt: NOW - 12 * M,
},
{
runId: "run_002",
policyId: "pol_security_default",
status: "FAILED",
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: "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) => ({
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,
@@ -108,6 +135,6 @@ export function seedPolicyRuns(): PolicyRunView[] {
error: null,
outputs: [] as { fileId: string; fileName: string }[],
createdAt: NOW - (34 * D + i * 10 * M),
})),
];
}));
return [...delivered, ...failed, ...older];
}
@@ -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. */}
<HomeHero tier={tier} />
<ProcessorFlow />
{/* One unified layout across tiers: real processed-PDF volume, real audit
activity, quick actions, and the standing-policy summary. */}
@@ -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<InfraTab>("deployments");
const { setActiveView } = useView();
const [searchParams, setSearchParams] = useSearchParams();
// Deep-link (?tab=<key>) 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<InfraTab>[] = [
{ key: "deployments", label: t("portal.infrastructure.tabs.deployments") },
+16 -1
View File
@@ -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<CatalogueEntry | null>(null);
const [busy, setBusy] = useState(false);
const [pageError, setPageError] = useState<string | null>(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();
+13 -11
View File
@@ -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 {