Compare commits

...
Author SHA1 Message Date
EthanHealy01andGitHub ab1874b39f Merge branch 'main' into feature/policiesVisualiserComponent 2026-07-14 12:18:15 +01:00
James BruntonandGitHub 776749277c Redesign policies to use typed mappings properly (#7017)
# Description of Changes
The Policies page and all the frontend logic for running Policies is not
making use of the bidirectional type mappings that we now have to safely
convert from frontend to backend param models and vice versa. This
changes the way we track the types throughout so we use the mappings
properly.

Because of this, the Add Watermark settings in Policies now actually
pre-populate with the defaults instead of with nothing like they
previously did.

<img width="791" height="725" alt="image"
src="https://github.com/user-attachments/assets/cbdf4ae0-35af-4792-bf64-89216e48d304"
/>
2026-07-14 09:58:04 +00:00
James BruntonandGitHub 41b1b89fcb Fix Policies page showing the Editor as a source twice (#7022)
# Description of Changes
The Policies page currently hard-codes the Editor to be available as a
source, but we now also have a virtual Editor source on the backend,
which the Policies page also renders. This removes the now-unnecessary
hard-coded Editor source.

## Before

<img width="842" height="640" alt="image"
src="https://github.com/user-attachments/assets/d78b33a3-fed4-4bb0-a02f-489ca2ae0614"
/>

## After

<img width="785" height="586" alt="image"
src="https://github.com/user-attachments/assets/37aa664f-74f2-41c7-b89a-9b483bffc3a2"
/>
2026-07-14 09:43:07 +00:00
James BruntonandGitHub 4d4e994562 Fix crash in Processor when loading tool settings with tooltips (#7015)
# Description of Changes
Some of the tool settings make use of editor preferences indirectly, but
the Processor never gets that provider, so it crashes when trying to
load them.
2026-07-14 09:09:23 +00:00
EthanHealy01andGitHub fc210bc761 Merge branch 'main' into feature/policiesVisualiserComponent 2026-07-13 18:45:05 +01:00
EthanHealy01 e3802c56b2 fix colors 2026-07-13 17:37:13 +01:00
EthanHealy01 c59571f17c pre commit fix and other fixes 2026-07-13 11:40:41 +01:00
EthanHealy01 7cdf72c5f9 break flow into subfiles, it was massive 2026-07-13 02:17:52 +01:00
32 changed files with 2630 additions and 278 deletions
+17 -1
View File
@@ -43,14 +43,30 @@ 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() } },
resources: { en: { 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 synchronously so t() never renders raw keys.
i18next.addResourceBundle(
"en",
"translation",
enTranslationResources,
true,
true,
);
if (i18next.language !== "en") void i18next.changeLanguage("en");
}
// Start MSW once. Storybook runs in a browser so this uses the service worker.
@@ -7708,6 +7708,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"
@@ -13,7 +13,6 @@ import {
import {
deserializeToolStep,
getExecutableTools,
serializeStepFromEndpoint,
serializeToolStep,
stepRequiresUpload,
type WorkingToolStep,
@@ -199,41 +198,6 @@ describe("serialize/deserialize round-trip", () => {
});
});
describe("serializeStepFromEndpoint", () => {
test("maps a wizard step's UI params to the backend contract, filling defaults", () => {
// The shape the policy setup wizard holds: an endpoint plus UI-shaped params
// (redact's `wordsToRedact`), with several fields left to their defaults.
const api = serializeStepFromEndpoint(
"/api/v1/security/auto-redact",
{ mode: "automatic", useRegex: true, wordsToRedact: ["ssn", "card"] },
dynamicRegistry,
);
expect(api.operation).toBe("/api/v1/security/auto-redact");
// wordsToRedact -> listOfText (the field the backend actually reads), and the
// frontend-only `mode` is dropped.
expect(api.parameters).toMatchObject({ listOfText: "ssn\ncard" });
expect(api.parameters).not.toHaveProperty("wordsToRedact");
expect(api.parameters).not.toHaveProperty("mode");
// Fields the wizard never set still get their defaults so the body is complete.
expect(api.parameters).toHaveProperty("wholeWordSearch");
expect(api.parameters).toHaveProperty("customPadding");
});
test("passes an unmapped endpoint's params through unchanged", () => {
expect(
serializeStepFromEndpoint(
"/api/v1/unknown/thing",
{ keep: true },
dynamicRegistry,
),
).toEqual({
operation: "/api/v1/unknown/thing",
parameters: { keep: true },
});
});
});
describe("stepRequiresUpload", () => {
const step = (params: Record<string, unknown>): WorkingToolStep => ({
toolId: "compress" as ToolId,
@@ -197,31 +197,6 @@ export function serializeToolStep(
return { operation, parameters };
}
/**
* Serialize a step held as an endpoint path plus frontend-shaped params - the form the policy setup
* wizard keeps, where params match the tool's UI shape (e.g. redact's `wordsToRedact`) rather than
* the backend contract - into the backend step contract, mapping params through the tool's
* `toApiParams` (merged over its defaults, so fields the wizard never set still get their defaults).
* The endpoint maps to a tool by path, so this works for dynamic-endpoint tools whose config
* endpoint is a function. Endpoints that map to no known tool pass through unchanged.
*/
export function serializeStepFromEndpoint(
operation: string,
params: ErasedToolParams,
registry: Partial<ToolRegistry>,
): ToolApiStep {
const match = findToolByEndpoint({ operation, parameters: params }, registry);
const config = match?.[1].operationConfig;
if (!config) return { operation, parameters: params };
const merged = { ...(config.defaultParameters ?? {}), ...params };
return {
operation: resolveEndpoint(config, merged) ?? operation,
parameters: config.toApiParams
? (config.toApiParams(merged) as Record<string, unknown>)
: {},
};
}
/**
* Find the registry tool for a stored step's endpoint: exact match for static endpoints, else
* membership in a dynamic tool's declared `endpoints` set (replaying its function can't recover a
@@ -0,0 +1,37 @@
import { describe, expect, test } from "vitest";
import { describeToolOperation } from "@app/hooks/tools/shared/toolOperationDescriptor";
interface Params {
a: number;
}
// A minimal config that type-checks against the flatten endpoint's model.
const CONFIG = {
endpoint: "/api/v1/misc/flatten" as const,
defaultParameters: { a: 1 } satisfies Params,
toApiParams: (p: Params) => ({ renderDpi: p.a }),
fromApiParams: (api: { renderDpi?: number }) => ({ a: api.renderDpi ?? 0 }),
};
describe("describeToolOperation", () => {
test("wraps the config's mappers and endpoint into a descriptor", () => {
const d = describeToolOperation("/api/v1/misc/flatten", CONFIG);
expect(d.endpoint).toBe("/api/v1/misc/flatten");
expect(d.toApi({ a: 200 })).toEqual({ renderDpi: 200 });
});
test("fromApi merges the mapped values over the defaults", () => {
const d = describeToolOperation("/api/v1/misc/flatten", CONFIG);
expect(d.fromApi({ renderDpi: 72 })).toEqual({ a: 72 });
});
test("throws when the config lacks a mapper", () => {
expect(() =>
describeToolOperation("/api/v1/misc/flatten", {
endpoint: "/api/v1/misc/flatten" as const,
defaultParameters: { a: 1 },
toApiParams: (p: Params) => ({ renderDpi: p.a }),
}),
).toThrow(/mappers/);
});
});
@@ -0,0 +1,59 @@
/**
* Typed wrapper over a tool's `toApiParams`/`fromApiParams` mappers, binding one endpoint to safe
* frontend<->backend parameter conversion.
*/
import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes";
export interface ToolOperationDescriptor<E extends ToolEndpoint, TParams> {
readonly endpoint: E;
readonly defaultParameters: TParams;
toApi(params: TParams): ToolApiParams[E];
/** Backend model -> full frontend params (defaults merged under the mapped values). */
fromApi(api: ToolApiParams[E]): TParams;
}
/**
* Structural subset of a tool's config. `CE` is the config's declared endpoint type, inferred from
* the `endpoint` field: the literal for static tools, or the whole `ToolEndpoint` union for
* dynamic-endpoint tools (whose endpoint is a function typed against the union).
*/
export interface BidirectionalToolConfig<TParams, CE extends ToolEndpoint> {
endpoint: CE | null | ((params: TParams) => CE | null);
defaultParameters?: TParams;
toApiParams?(params: TParams): ToolApiParams[CE];
fromApiParams?(api: ToolApiParams[CE]): Partial<TParams>;
}
/**
* Pin a config to `endpoint` (passed explicitly, since dynamic-endpoint tools declare `endpoint` as
* a function). `E extends CE` rejects pairing a static tool's config with the wrong endpoint, while
* allowing a dynamic tool whose `CE` is the full union. Throws when mappers or defaults are missing.
*/
export function describeToolOperation<
E extends CE,
CE extends ToolEndpoint,
TParams,
>(
endpoint: E,
config: BidirectionalToolConfig<TParams, CE>,
): ToolOperationDescriptor<E, TParams> {
const { toApiParams, fromApiParams, defaultParameters } = config;
if (!toApiParams || !fromApiParams || defaultParameters === undefined) {
throw new Error(
`describeToolOperation: "${endpoint}" is missing mappers or defaults`,
);
}
return {
endpoint,
defaultParameters,
// A dynamic tool's mapper is typed against the union; narrow to this endpoint (sound - the
// runtime mapper produces this endpoint's model).
toApi: (params) => toApiParams(params) as ToolApiParams[E],
fromApi: (api) =>
({
...defaultParameters,
...fromApiParams(api as ToolApiParams[CE]),
}) as TParams,
};
}
+22 -50
View File
@@ -13,6 +13,8 @@ import type { TFunction } from "i18next";
import { apiClient } from "@portal/api/http";
import { fromWirePolicy, toWirePolicy } from "@app/policies/codec";
import { runsToActivity, runsToStats } from "@app/policies/runs";
import { policyStep, type PolicyToolStep } from "@app/policies/operations";
import type { ToolEndpoint } from "@app/types/toolApiTypes";
import type {
PolicyDecodedState,
PolicyRunView,
@@ -66,7 +68,7 @@ export interface PolicyConfigDef {
rules: string[];
scopeLabel: string;
fields: PolicyField[];
defaultOperations: WirePipelineStep[];
defaultOperations: PolicyToolStep[];
}
export interface PolicyState {
@@ -128,20 +130,11 @@ export interface CatalogueEntry {
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Tool → endpoint registry */
/* Endpoint display labels */
/* ──────────────────────────────────────────────────────────────────────── */
export const TOOL_ENDPOINTS: Record<string, string> = {
redact: "/api/v1/security/auto-redact",
sanitize: "/api/v1/security/sanitize-pdf",
watermark: "/api/v1/security/add-watermark",
ocr: "/api/v1/misc/ocr-pdf",
flatten: "/api/v1/misc/flatten",
compress: "/api/v1/misc/compress-pdf",
};
/** Values are i18n keys — render with t(). */
export const ENDPOINT_LABELS: Record<string, string> = {
/** i18n keys keyed by {@link ToolEndpoint}; labels stored steps in the detail view. */
export const ENDPOINT_LABELS: Partial<Record<ToolEndpoint, string>> = {
"/api/v1/security/auto-redact": "portal.policies.endpoints.autoRedact",
"/api/v1/security/sanitize-pdf": "portal.policies.endpoints.sanitizePdf",
"/api/v1/security/add-watermark": "portal.policies.endpoints.addWatermark",
@@ -154,7 +147,8 @@ export function humanizeEndpoint(
path: string,
t: (key: string) => string,
): string {
if (ENDPOINT_LABELS[path]) return t(ENDPOINT_LABELS[path]);
const label = ENDPOINT_LABELS[path as ToolEndpoint];
if (label) return t(label);
const last = path.split("/").filter(Boolean).pop() ?? path;
return last
.replace(/-/g, " ")
@@ -230,10 +224,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
"portal.policies.config.ingestion.rules.3",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [
{ operation: TOOL_ENDPOINTS.ocr, parameters: {} },
{ operation: TOOL_ENDPOINTS.flatten, parameters: {} },
],
defaultOperations: [policyStep("ocr"), policyStep("flatten")],
fields: [
{
label: "portal.policies.config.ingestion.fields.minConfidence",
@@ -260,32 +251,16 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [
{
operation: TOOL_ENDPOINTS.redact,
parameters: {
mode: "automatic",
useRegex: true,
convertPDFToImage: true,
wordsToRedact: DEFAULT_PII_PATTERNS,
},
},
{
operation: TOOL_ENDPOINTS.sanitize,
parameters: {
removeJavaScript: true,
removeEmbeddedFiles: false,
removeMetadata: false,
removeLinks: false,
removeFonts: false,
},
},
{
operation: TOOL_ENDPOINTS.watermark,
// convertPDFToImage bakes the watermark in so it can't be stripped
parameters: {
convertPDFToImage: true,
},
},
// Flatten to image so redactions can't be lifted off.
policyStep("redact", {
useRegex: true,
convertPDFToImage: true,
wordsToRedact: DEFAULT_PII_PATTERNS,
}),
// JavaScript removal only; the tool enables removeEmbeddedFiles by default, so turn it off.
policyStep("sanitize", { removeEmbeddedFiles: false }),
// Bake in via image so it can't be stripped.
policyStep("watermark", { convertPDFToImage: true }),
],
fields: [],
},
@@ -297,10 +272,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
"portal.policies.config.compliance.rules.2",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [
{ operation: TOOL_ENDPOINTS.sanitize, parameters: {} },
{ operation: TOOL_ENDPOINTS.flatten, parameters: {} },
],
defaultOperations: [policyStep("sanitize"), policyStep("flatten")],
fields: [
{
label: "portal.policies.config.compliance.fields.frameworks",
@@ -343,7 +315,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
"portal.policies.config.routing.rules.2",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }],
defaultOperations: [policyStep("compress")],
fields: [
{
label: "portal.policies.config.routing.fields.destination",
@@ -374,7 +346,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
"portal.policies.config.retention.rules.2",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }],
defaultOperations: [policyStep("compress")],
fields: [
{
label: "portal.policies.config.retention.fields.keepFor",
@@ -0,0 +1,176 @@
/**
* 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). */
key: string;
/** i18n key for the category label. */
labelKey: string;
/** Material Symbols icon name (from the catalogue). */
icon: 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,
icon: cat.icon,
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-subtle);
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: var(--color-green-light);
color: var(--color-green-dark);
}
.portal-pf__node--failed .portal-pf__node-icon {
background: var(--color-red-light);
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-subtle);
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: var(--color-green-light);
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,52 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { Tooltip } from "@app/components/shared/Tooltip";
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
import type { WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback ?? key,
}),
}));
// A stand-in tool-settings UI that uses the shared editor Tooltip. The Tooltip
// pulls in the Preferences + Sidebar contexts, which the portal does not mount
// app-wide — so this reproduces the "usePreferences must be used within a
// PreferencesProvider" crash unless PipelineStepSettings supplies them.
function TooltipSettings() {
return (
<Tooltip content="help">
<button type="button">field</button>
</Tooltip>
);
}
const step = {
support: "editable",
toolId: "compress",
params: {},
} as unknown as WorkingToolStep;
const registry = {
compress: { automationSettings: TooltipSettings },
} as unknown as Partial<ToolRegistry>;
describe("PipelineStepSettings", () => {
it("renders reused editor tool settings (which use the shared Tooltip) without app-wide Preferences/Sidebar providers", () => {
expect(() =>
render(
<MantineProvider>
<PipelineStepSettings
step={step}
registry={registry}
onChange={() => {}}
/>
</MantineProvider>,
),
).not.toThrow();
expect(screen.getByText("field")).toBeInTheDocument();
});
});
@@ -1,6 +1,8 @@
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import { Banner } from "@app/ui";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import { SidebarProvider } from "@app/contexts/SidebarContext";
import { type ToolRegistry } from "@app/data/toolsTaxonomy";
import { type ErasedToolParams } from "@app/hooks/tools/shared/toolOperationTypes";
import { type WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
@@ -46,14 +48,18 @@ export function PipelineStepSettings({
}
return (
<Suspense fallback={null}>
<Settings
parameters={step.params}
onParameterChange={(key, value) =>
onChange({ ...step.params, [key]: value })
}
disabled={false}
/>
</Suspense>
<PreferencesProvider>
<SidebarProvider>
<Suspense fallback={null}>
<Settings
parameters={step.params}
onParameterChange={(key, value) =>
onChange({ ...step.params, [key]: value })
}
disabled={false}
/>
</Suspense>
</SidebarProvider>
</PreferencesProvider>
);
}
@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
fireEvent,
render as baseRender,
screen,
waitFor,
} from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard";
import {
POLICY_CATEGORIES,
POLICY_CONFIG,
type CatalogueEntry,
type DecoratedPolicy,
type PolicySetupResult,
type PipelineStep,
} from "@portal/api/policies";
const render = (ui: Parameters<typeof baseRender>[0]) =>
baseRender(ui, { wrapper: MantineProvider });
// Deterministic i18n: return the fallback when given, else the key. initReactI18next is stubbed
// because the import graph pulls core/i18n.ts, which registers it as a plugin.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
// Second arg is a string fallback in some call sites and an interpolation object in others;
// only treat a string as the fallback.
t: (key: string, fallback?: unknown) =>
typeof fallback === "string" ? fallback : key,
i18n: { changeLanguage: vi.fn() },
}),
initReactI18next: { type: "3rdParty", init: vi.fn() },
}));
const fetchSources = vi.fn();
vi.mock("@portal/api/sources", () => ({
fetchSources: () => fetchSources(),
}));
const CONTINUE = "portal.policies.wizard.actions.continue";
const SAVE_CHANGES = "portal.policies.wizard.actions.saveChanges";
const ENABLE = "portal.policies.wizard.actions.enablePolicy";
const security = POLICY_CATEGORIES.find((c) => c.id === "security")!;
const securityConfig = POLICY_CONFIG.security;
function editEntry(steps: PipelineStep[]): CatalogueEntry {
const policy: DecoratedPolicy = {
category: security,
config: securityConfig,
state: {
configured: true,
status: "active",
sources: ["editor"],
scopeTypes: [],
reviewerEmail: "",
fieldValues: {},
runOn: "upload",
outputMode: "new_version",
outputName: "",
outputNamePosition: "suffix",
maxRetries: 0,
retryDelayMinutes: 0,
backendId: "pol-1",
isDefault: true,
},
steps,
stats: { enforced: 0, dataProcessed: "-", activeFor: "-" },
activity: [],
};
return { category: security, config: securityConfig, policy };
}
/** Advance the wizard from the workflow tab to the settings tab and submit. */
async function submitWizard(saveLabel: string) {
fireEvent.click(await screen.findByRole("button", { name: CONTINUE }));
fireEvent.click(await screen.findByRole("button", { name: saveLabel }));
}
describe("PolicySetupWizard", () => {
beforeEach(() => {
fetchSources.mockResolvedValue({ sources: [] });
});
it("round-trips a saved step's backend params on edit", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
const entry = editEntry([
{
operation: "/api/v1/security/auto-redact",
parameters: { listOfText: "foo\nbar", useRegex: true },
},
]);
render(
<PolicySetupWizard entry={entry} onClose={vi.fn()} onSubmit={onSubmit} />,
);
await submitWizard(SAVE_CHANGES);
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
const result = onSubmit.mock.calls[0][1] as PolicySetupResult;
// Only the saved tool is enabled on edit, and its patterns survive the wire -> UI -> wire trip.
expect(result.steps).toEqual([
expect.objectContaining({
operation: "/api/v1/security/auto-redact",
parameters: expect.objectContaining({ listOfText: "foo\nbar" }),
}),
]);
});
it("seeds the preset chain for a new policy (redact + sanitize on, watermark off)", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
const entry: CatalogueEntry = {
category: security,
config: securityConfig,
policy: null,
};
render(
<PolicySetupWizard entry={entry} onClose={vi.fn()} onSubmit={onSubmit} />,
);
await submitWizard(ENABLE);
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
const result = onSubmit.mock.calls[0][1] as PolicySetupResult;
const endpoints = result.steps.map((s) => s.operation);
expect(endpoints).toEqual([
"/api/v1/security/auto-redact",
"/api/v1/security/sanitize-pdf",
]);
// Redact carries the preset PII patterns as the backend's listOfText.
const redact = result.steps[0].parameters as { listOfText?: string };
expect(redact.listOfText).toBeTruthy();
});
});
@@ -13,23 +13,24 @@ import {
} from "@app/ui";
import { SettingsRow } from "@app/ui/SettingsRow";
import {
TOOL_ENDPOINTS,
humanizeEndpoint,
type CatalogueEntry,
type PipelineStep,
type PolicySetupResult,
} from "@portal/api/policies";
import type { ToolRegistry, ToolRegistryEntry } from "@app/data/toolsTaxonomy";
import {
deserializeToolStep,
serializeStepFromEndpoint,
} from "@app/hooks/tools/shared/toolAutomation";
policyEndpoint,
policyStepFromWire,
policyStepToWire,
type PolicyParams,
type PolicyToolId,
type PolicyToolStep,
} from "@app/policies/operations";
import { fetchSources } from "@portal/api/sources";
import { useAsync } from "@portal/hooks/useAsync";
import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow";
import { policyIcon } from "@portal/components/policies/policyIcons";
import { sourceTypeMeta } from "@portal/components/sources/sourceTypes";
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig";
import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkConfig";
import "@portal/views/Policies.css";
@@ -47,12 +48,8 @@ interface PolicySetupWizardProps {
type Step = "workflow" | "settings";
/** A configurable tool in the workflow step: whether it runs + its params. */
interface ToolState {
operation: string;
enabled: boolean;
parameters: Record<string, unknown>;
}
/** A policy step plus whether it runs. */
type ToolState = PolicyToolStep & { enabled: boolean };
/** Resolve each field's effective value: saved override, else definition default. */
function resolveFieldValues(
@@ -69,9 +66,8 @@ function resolveFieldValues(
* round-trips); otherwise the category preset's default chain. Each preset step
* starts enabled — the user toggles tools off in the workflow.
*/
// Temporary: tracks which tools start disabled until the tool registry lands in
// the portal and can drive this via registry metadata or a defaultEnabled flag.
const DISABLED_BY_DEFAULT = new Set(["/api/v1/security/add-watermark"]);
// Temporary until the catalogue carries a defaultEnabled flag.
const DISABLED_BY_DEFAULT = new Set<PolicyToolId>(["watermark"]);
/**
* Policy-facing framing for each capability a policy can include. Labels and
@@ -81,43 +77,43 @@ const DISABLED_BY_DEFAULT = new Set(["/api/v1/security/add-watermark"]);
* the humanised endpoint name with no description.
*/
const CAPABILITY_META: Record<
string,
PolicyToolId,
{ labelKey: string; labelEn: string; descKey: string; descEn: string }
> = {
[TOOL_ENDPOINTS.redact]: {
redact: {
labelKey: "portal.policies.wizard.capability.redact.label",
labelEn: "Redact sensitive information",
descKey: "portal.policies.wizard.capability.redact.desc",
descEn:
"Finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read.",
},
[TOOL_ENDPOINTS.sanitize]: {
sanitize: {
labelKey: "portal.policies.wizard.capability.sanitize.label",
labelEn: "Strip active content",
descKey: "portal.policies.wizard.capability.sanitize.desc",
descEn:
"Removes hidden JavaScript so nothing can run automatically when the document is opened.",
},
[TOOL_ENDPOINTS.watermark]: {
watermark: {
labelKey: "portal.policies.wizard.capability.watermark.label",
labelEn: "Apply a watermark",
descKey: "portal.policies.wizard.capability.watermark.desc",
descEn: "Stamps a visible mark (e.g. “Confidential”) across every page.",
},
[TOOL_ENDPOINTS.ocr]: {
ocr: {
labelKey: "portal.policies.wizard.capability.ocr.label",
labelEn: "Make text searchable",
descKey: "portal.policies.wizard.capability.ocr.desc",
descEn: "Runs OCR so scanned pages become selectable, searchable text.",
},
[TOOL_ENDPOINTS.flatten]: {
flatten: {
labelKey: "portal.policies.wizard.capability.flatten.label",
labelEn: "Flatten the document",
descKey: "portal.policies.wizard.capability.flatten.desc",
descEn:
"Merges form fields and annotations into the page so they can't be edited.",
},
[TOOL_ENDPOINTS.compress]: {
compress: {
labelKey: "portal.policies.wizard.capability.compress.label",
labelEn: "Reduce file size",
descKey: "portal.policies.wizard.capability.compress.desc",
@@ -125,29 +121,24 @@ const CAPABILITY_META: Record<
},
};
function seedTools(
entry: CatalogueEntry,
registry: Partial<ToolRegistry>,
): ToolState[] {
function seedTools(entry: CatalogueEntry): ToolState[] {
const savedSteps = entry.policy?.steps ?? [];
const savedByOp = new Map(savedSteps.map((s) => [s.operation, s]));
// Always use defaultOperations as the canonical list so tools added after a
// policy was first saved still appear when editing.
return entry.config.defaultOperations.map((s) => {
const saved = savedByOp.get(s.operation);
const savedByTool = new Map<PolicyToolId, PolicyToolStep>();
for (const wire of savedSteps) {
const step = policyStepFromWire(wire);
if (step) savedByTool.set(step.toolId, step);
}
// defaultOperations is the canonical list (so tools added later still show on edit); a saved
// step's params win over the preset.
return entry.config.defaultOperations.map((preset) => {
const saved = savedByTool.get(preset.toolId);
return {
operation: s.operation,
...(saved ?? preset),
enabled: saved
? true
: savedSteps.length > 0
? false
: !DISABLED_BY_DEFAULT.has(s.operation),
// Saved steps are in the backend contract shape; map them back to the UI
// shape the config controls edit (e.g. `listOfText` -> `wordsToRedact`).
// Presets are already authored in the UI shape, so use them as-is.
parameters: saved
? deserializeToolStep(saved, registry).params
: s.parameters,
: !DISABLED_BY_DEFAULT.has(preset.toolId),
};
});
}
@@ -185,26 +176,12 @@ function PolicySetupWizardBody({
onSubmit: (entry: CatalogueEntry, result: PolicySetupResult) => Promise<void>;
}) {
const { t } = useTranslation();
const { allTools: toolRegistry } = useToolRegistry();
// Portal tool operations are endpoint paths (/api/v1/…), not short registry IDs.
// Build a reverse map so we can look up icons and display names by endpoint.
const registryByEndpoint = useMemo(() => {
const map = new Map<string, ToolRegistryEntry>();
for (const entry of Object.values(toolRegistry)) {
const ep = (entry as ToolRegistryEntry).operationConfig?.endpoint;
if (typeof ep === "string") map.set(ep, entry as ToolRegistryEntry);
}
return map;
}, [toolRegistry]);
const { category, config, policy } = entry;
const isEdit = policy != null;
const [step, setStep] = useState<Step>("workflow");
const [tools, setTools] = useState<ToolState[]>(() =>
seedTools(entry, toolRegistry),
);
const [tools, setTools] = useState<ToolState[]>(() => seedTools(entry));
const [fieldValues, setFieldValues] = useState(() =>
resolveFieldValues(entry),
);
@@ -213,22 +190,11 @@ function PolicySetupWizardBody({
);
const sourcesAsync = useAsync(() => fetchSources(), []);
const availableSources = useMemo(() => {
const backendSources = (sourcesAsync.data?.sources ?? []).filter(
(s) => s.status !== "disabled",
);
const editorSource = {
id: "editor",
name: t("portal.sources.types.editor.label"),
type: "editor",
status: "active" as const,
referenceCount: 0,
referencingPolicies: [],
config: [],
docsTotal: null,
};
return [editorSource, ...backendSources];
}, [sourcesAsync.data, t]);
const availableSources = useMemo(
() =>
(sourcesAsync.data?.sources ?? []).filter((s) => s.status !== "disabled"),
[sourcesAsync.data],
);
// Document-type scoping has no UI; preserve any saved scope on edit and
// default new policies to all document types.
const [scopeTypes] = useState<string[]>(policy?.state.scopeTypes ?? []);
@@ -256,9 +222,20 @@ function PolicySetupWizardBody({
const enabledTools = useMemo(() => tools.filter((tl) => tl.enabled), [tools]);
function patchTool(operation: string, patch: Partial<ToolState>) {
function setToolEnabled(toolId: PolicyToolId, enabled: boolean) {
setTools((prev) =>
prev.map((tl) => (tl.operation === operation ? { ...tl, ...patch } : tl)),
prev.map((tl) => (tl.toolId === toolId ? { ...tl, enabled } : tl)),
);
}
function setToolParams<Id extends PolicyToolId>(
toolId: Id,
params: PolicyParams<Id>,
) {
setTools((prev) =>
prev.map((tl) =>
tl.toolId === toolId ? ({ ...tl, params } as ToolState) : tl,
),
);
}
@@ -277,11 +254,8 @@ function PolicySetupWizardBody({
}
setError(null);
setSubmitting(true);
// Map each tool's UI-shaped params (e.g. redact's `wordsToRedact`) into the
// backend step contract (e.g. `listOfText`) via its `toApiParams`; saving the
// UI shape verbatim would drop those fields and the step would run with none.
const steps: PipelineStep[] = enabledTools.map((tl) =>
serializeStepFromEndpoint(tl.operation, tl.parameters, toolRegistry),
policyStepToWire(tl),
);
try {
await onSubmit(entry, {
@@ -386,20 +360,16 @@ function PolicySetupWizardBody({
<Card padding="none">
<div className="portal-policies__capabilities">
{tools.map((tl) => {
const meta = CAPABILITY_META[tl.operation];
const meta = CAPABILITY_META[tl.toolId];
const label = meta
? t(meta.labelKey, meta.labelEn)
: (registryByEndpoint.get(tl.operation)?.name ??
humanizeEndpoint(tl.operation, t));
: humanizeEndpoint(policyEndpoint(tl.toolId), t);
const description = meta
? t(meta.descKey, meta.descEn)
: undefined;
const hasConfig =
tl.operation === TOOL_ENDPOINTS.redact ||
tl.operation === TOOL_ENDPOINTS.watermark;
return (
<div
key={tl.operation}
key={tl.toolId}
className="portal-policies__capability"
data-on={tl.enabled || undefined}
>
@@ -411,27 +381,27 @@ function PolicySetupWizardBody({
size="sm"
checked={tl.enabled}
onChange={(checked) =>
patchTool(tl.operation, { enabled: checked })
setToolEnabled(tl.toolId, checked)
}
label=""
/>
}
/>
{tl.enabled && hasConfig && (
{tl.enabled && (
<div className="portal-policies__capability-config">
{tl.operation === TOOL_ENDPOINTS.redact && (
{tl.toolId === "redact" && (
<PolicyRedactConfig
parameters={tl.parameters}
onChange={(parameters) =>
patchTool(tl.operation, { parameters })
parameters={tl.params}
onChange={(params) =>
setToolParams("redact", params)
}
/>
)}
{tl.operation === TOOL_ENDPOINTS.watermark && (
{tl.toolId === "watermark" && (
<PolicyWatermarkConfig
parameters={tl.parameters}
onChange={(parameters) =>
patchTool(tl.operation, { parameters })
parameters={tl.params}
onChange={(params) =>
setToolParams("watermark", params)
}
/>
)}
@@ -475,9 +445,8 @@ function PolicySetupWizardBody({
{t("portal.policies.wizard.sources.loading")}
</p>
) : (
// The editor is always an available source (unconditionally prepended
// to availableSources), so the list is never empty — no "no sources"
// state exists.
// The backend always returns the editor as a virtual source, so the
// loaded list is never empty - no "no sources" state exists.
<div className="portal-policies__sources">
{availableSources.map((src) => (
// A selectable multi-line tile (icon + name + type + check).
@@ -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,80 @@
import { type RefObject } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui";
import LocalIcon from "@app/components/shared/LocalIcon";
import type { FlowPolicy } from "@portal/api/processorFlow";
import { ICON_SIZE } from "@portal/components/processor-flow/flowTypes";
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>
<LocalIcon icon={policy.icon} width={ICON_SIZE} />
</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;
}
+91 -48
View File
@@ -4,13 +4,33 @@
* only builds seed data for the MSW handlers and tests.
*/
import type { PolicyRunView, WirePolicy } from "@app/policies/types";
import { POLICY_CONFIG } from "@portal/api/policies";
import type {
PolicyRunView,
WirePipelineStep,
WirePolicy,
} from "@app/policies/types";
/* ──────────────────────────────────────────────────────────────────────── */
/* Seed data — real backend wire format */
/* ──────────────────────────────────────────────────────────────────────── */
// Literal wire steps (not derived from the catalogue) so this fixtures module stays independent of
// @portal/api/policies and its heavy tool-operation import graph.
const SECURITY_STEPS: WirePipelineStep[] = [
{
operation: "/api/v1/security/auto-redact",
parameters: {
listOfText: "",
useRegex: true,
convertPDFToImage: true,
},
},
{
operation: "/api/v1/security/sanitize-pdf",
parameters: { removeJavaScript: true },
},
];
export function seedPolicies(): WirePolicy[] {
return [
{
@@ -19,7 +39,7 @@ export function seedPolicies(): WirePolicy[] {
owner: "security@acme.com",
enabled: true,
trigger: null,
steps: POLICY_CONFIG.security.defaultOperations,
steps: SECURITY_STEPS,
output: {
type: "inline",
options: {
@@ -37,57 +57,80 @@ export function seedPolicies(): WirePolicy[] {
},
},
},
{
id: "pol_ingestion_default",
name: "Ingestion Policy",
owner: "data-eng@acme.com",
enabled: true,
trigger: null,
steps: POLICY_CONFIG.ingestion.defaultOperations,
output: {
type: "inline",
options: {
runOn: "upload",
mode: "new_version",
name: "",
position: "suffix",
maxRetries: 3,
retryDelayMinutes: 5,
categoryId: "ingestion",
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 / ingestion)
// 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_ingestion_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];
}
@@ -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";
@@ -33,6 +34,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 catalogue = data?.catalogue ?? [];
const refetch = useCallback(() => setVersion((v) => v + 1), []);
@@ -1,6 +1,7 @@
import { MultiSelect } from "@app/ui/MultiSelect";
import { useTranslation } from "react-i18next";
import { PII_PRESETS } from "@app/data/policyDefinitions";
import type { RedactParameters } from "@app/hooks/tools/redact/useRedactParameters";
/** The set of preset regexes — used to separate preset words from custom ones. */
export const PRESET_PATTERNS = new Set(PII_PRESETS.map((p) => p.pattern));
@@ -8,8 +9,8 @@ const PATTERN_BY_VALUE = new Map(PII_PRESETS.map((p) => [p.value, p.pattern]));
const VALUE_BY_PATTERN = new Map(PII_PRESETS.map((p) => [p.pattern, p.value]));
interface PolicyPiiFieldProps {
parameters: Record<string, unknown>;
onChange: (parameters: Record<string, unknown>) => void;
parameters: RedactParameters;
onChange: (parameters: RedactParameters) => void;
disabled?: boolean;
}
@@ -26,9 +27,7 @@ export function PolicyPiiField({
disabled,
}: PolicyPiiFieldProps) {
const { t } = useTranslation();
const words = Array.isArray(parameters.wordsToRedact)
? (parameters.wordsToRedact as string[])
: [];
const words = parameters.wordsToRedact;
const selected = words
.map((w) => VALUE_BY_PATTERN.get(w))
.filter((v): v is string => Boolean(v));
@@ -1,9 +1,10 @@
import { useEffect } from "react";
import { PolicyPiiField } from "@app/components/policies/PolicyPiiField";
import type { RedactParameters } from "@app/hooks/tools/redact/useRedactParameters";
interface PolicyRedactConfigProps {
parameters: Record<string, unknown>;
onChange: (parameters: Record<string, unknown>) => void;
parameters: RedactParameters;
onChange: (parameters: RedactParameters) => void;
disabled?: boolean;
}
@@ -3,8 +3,8 @@ import AddWatermarkSingleStepSettings from "@app/components/tools/addWatermark/A
import type { AddWatermarkParameters } from "@app/hooks/tools/addWatermark/useAddWatermarkParameters";
interface PolicyWatermarkConfigProps {
parameters: Record<string, unknown>;
onChange: (parameters: Record<string, unknown>) => void;
parameters: AddWatermarkParameters;
onChange: (parameters: AddWatermarkParameters) => void;
disabled?: boolean;
}
@@ -20,7 +20,7 @@ export function PolicyWatermarkConfig({
disabled,
}: PolicyWatermarkConfigProps) {
useEffect(() => {
const patch: Record<string, unknown> = {};
const patch: Partial<AddWatermarkParameters> = {};
if (parameters.convertPDFToImage !== true) patch.convertPDFToImage = true;
// Policies only support text watermarks.
if (parameters.watermarkType !== "text") patch.watermarkType = "text";
@@ -29,7 +29,7 @@ export function PolicyWatermarkConfig({
return (
<AddWatermarkSingleStepSettings
parameters={parameters as unknown as AddWatermarkParameters}
parameters={parameters}
onParameterChange={(key, value) =>
onChange({ ...parameters, [key]: value })
}
@@ -0,0 +1,112 @@
import { describe, expect, test } from "vitest";
import {
POLICY_OPERATIONS,
policyEndpoint,
policyStep,
policyStepFromWire,
policyStepToWire,
policyToolIdForEndpoint,
type PolicyToolId,
} from "@app/policies/operations";
const ALL_TOOL_IDS = Object.keys(POLICY_OPERATIONS) as PolicyToolId[];
describe("POLICY_OPERATIONS", () => {
test("every category operation is a typed descriptor with a known endpoint", () => {
// The catalogue uses these six across all categories; each must be wired.
expect(ALL_TOOL_IDS.sort()).toEqual([
"compress",
"flatten",
"ocr",
"redact",
"sanitize",
"watermark",
]);
for (const id of ALL_TOOL_IDS) {
expect(POLICY_OPERATIONS[id].endpoint).toBe(policyEndpoint(id));
expect(typeof POLICY_OPERATIONS[id].toApi).toBe("function");
expect(typeof POLICY_OPERATIONS[id].fromApi).toBe("function");
}
});
test("policyEndpoint returns the pinned endpoint literal", () => {
expect(policyEndpoint("redact")).toBe("/api/v1/security/auto-redact");
expect(policyEndpoint("sanitize")).toBe("/api/v1/security/sanitize-pdf");
expect(policyEndpoint("watermark")).toBe("/api/v1/security/add-watermark");
expect(policyEndpoint("ocr")).toBe("/api/v1/misc/ocr-pdf");
expect(policyEndpoint("flatten")).toBe("/api/v1/misc/flatten");
expect(policyEndpoint("compress")).toBe("/api/v1/misc/compress-pdf");
});
test("policyToolIdForEndpoint maps endpoints back, and rejects non-policy ones", () => {
for (const id of ALL_TOOL_IDS) {
expect(policyToolIdForEndpoint(policyEndpoint(id))).toBe(id);
}
expect(policyToolIdForEndpoint("/api/v1/misc/repair")).toBeNull();
expect(policyToolIdForEndpoint("not-an-endpoint")).toBeNull();
});
});
describe("policyStep", () => {
test("merges partial params over the tool's defaults", () => {
const step = policyStep("redact", {
useRegex: true,
wordsToRedact: ["ssn", "card"],
});
expect(step.toolId).toBe("redact");
// Overrides applied...
expect(step.params.useRegex).toBe(true);
expect(step.params.wordsToRedact).toEqual(["ssn", "card"]);
// ...and untouched fields fall back to the tool's defaults.
expect(step.params.mode).toBe("automatic");
expect(step.params.redactColor).toBe("#000000");
});
});
describe("wire conversion", () => {
test("redact maps frontend params to the backend request model (wordsToRedact -> listOfText)", () => {
const wire = policyStepToWire(
policyStep("redact", {
useRegex: true,
convertPDFToImage: true,
wordsToRedact: ["ssn", "card"],
}),
);
expect(wire.operation).toBe("/api/v1/security/auto-redact");
// The backend field the endpoint actually reads, and no frontend-only `mode`/`wordsToRedact`.
expect(wire.parameters).toMatchObject({
listOfText: "ssn\ncard",
useRegex: true,
convertPDFToImage: true,
});
expect(wire.parameters).not.toHaveProperty("wordsToRedact");
expect(wire.parameters).not.toHaveProperty("mode");
});
test("every policy operation round-trips through wire and back", () => {
for (const id of ALL_TOOL_IDS) {
const step = policyStep(id);
const back = policyStepFromWire(policyStepToWire(step));
expect(back?.toolId).toBe(id);
}
});
test("redact round-trip preserves the configured patterns", () => {
const step = policyStep("redact", {
useRegex: true,
wordsToRedact: ["ssn", "card"],
});
const back = policyStepFromWire(policyStepToWire(step));
expect(back?.toolId).toBe("redact");
if (back?.toolId === "redact") {
expect(back.params.wordsToRedact).toEqual(["ssn", "card"]);
expect(back.params.useRegex).toBe(true);
}
});
test("a non-policy endpoint decodes to null", () => {
expect(
policyStepFromWire({ operation: "/api/v1/misc/repair", parameters: {} }),
).toBeNull();
});
});
@@ -0,0 +1,131 @@
/**
* The tool operations the Policies feature can run, each a typed {@link ToolOperationDescriptor}.
* Source of truth for the catalogue, wizard, and wire conversion. Add a tool here to use it in a
* policy - the catalogue can't reference an untyped operation.
*/
import { describeToolOperation } from "@app/hooks/tools/shared/toolOperationDescriptor";
import { redactOperationConfig } from "@app/hooks/tools/redact/useRedactOperation";
import { sanitizeOperationConfig } from "@app/hooks/tools/sanitize/useSanitizeOperation";
import { addWatermarkOperationConfig } from "@app/hooks/tools/addWatermark/useAddWatermarkOperation";
import { ocrOperationConfig } from "@app/hooks/tools/ocr/useOCROperation";
import { flattenOperationConfig } from "@app/hooks/tools/flatten/useFlattenOperation";
import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
import type { ToolOperationDescriptor } from "@app/hooks/tools/shared/toolOperationDescriptor";
import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes";
import type { WirePipelineStep } from "@app/policies/types";
export const POLICY_OPERATIONS = {
redact: describeToolOperation(
"/api/v1/security/auto-redact",
redactOperationConfig,
),
sanitize: describeToolOperation(
"/api/v1/security/sanitize-pdf",
sanitizeOperationConfig,
),
watermark: describeToolOperation(
"/api/v1/security/add-watermark",
addWatermarkOperationConfig,
),
ocr: describeToolOperation("/api/v1/misc/ocr-pdf", ocrOperationConfig),
flatten: describeToolOperation(
"/api/v1/misc/flatten",
flattenOperationConfig,
),
compress: describeToolOperation(
"/api/v1/misc/compress-pdf",
compressOperationConfig,
),
} as const;
export type PolicyToolId = keyof typeof POLICY_OPERATIONS;
export type PolicyParams<Id extends PolicyToolId> =
(typeof POLICY_OPERATIONS)[Id] extends ToolOperationDescriptor<
ToolEndpoint,
infer P
>
? P
: never;
/** Discriminated on `toolId` so `params` matches the tool. */
export type PolicyToolStep = {
[Id in PolicyToolId]: { toolId: Id; params: PolicyParams<Id> };
}[PolicyToolId];
export type PolicyToolStepOf<Id extends PolicyToolId> = Extract<
PolicyToolStep,
{ toolId: Id }
>;
const POLICY_TOOL_IDS = Object.keys(POLICY_OPERATIONS) as PolicyToolId[];
const TOOL_ID_BY_ENDPOINT = new Map<string, PolicyToolId>(
POLICY_TOOL_IDS.map((id) => [POLICY_OPERATIONS[id].endpoint, id]),
);
export function policyEndpoint(toolId: PolicyToolId): ToolEndpoint {
return POLICY_OPERATIONS[toolId].endpoint;
}
/** Tool id for an endpoint path, or null if it isn't a policy tool. */
export function policyToolIdForEndpoint(endpoint: string): PolicyToolId | null {
return TOOL_ID_BY_ENDPOINT.get(endpoint) ?? null;
}
/** A step for `toolId`, partial params merged over the tool's defaults. */
export function policyStep<Id extends PolicyToolId>(
toolId: Id,
params: Partial<PolicyParams<Id>> = {},
): PolicyToolStepOf<Id> {
const defaults = POLICY_OPERATIONS[toolId].defaultParameters as object;
return {
toolId,
params: { ...defaults, ...(params as object) },
} as PolicyToolStepOf<Id>;
}
export function policyStepToWire(step: PolicyToolStep): WirePipelineStep {
return serializeStep(step);
}
// Generic over the id so `params` stays correlated with the descriptor; TS can't do that through
// the union, so `op` is widened here (a contained cast at the wire boundary).
function serializeStep<Id extends PolicyToolId>(step: {
toolId: Id;
params: PolicyParams<Id>;
}): WirePipelineStep {
const op = POLICY_OPERATIONS[step.toolId] as ToolOperationDescriptor<
ToolEndpoint,
PolicyParams<Id>
>;
return {
operation: op.endpoint,
parameters: op.toApi(step.params) as Record<string, unknown>,
};
}
/** Wire step -> typed policy step, or null if the endpoint isn't a policy tool. */
export function policyStepFromWire(
wire: WirePipelineStep,
): PolicyToolStep | null {
const toolId = policyToolIdForEndpoint(wire.operation);
if (!toolId) return null;
return deserializeStep(toolId, wire.parameters);
}
function deserializeStep<Id extends PolicyToolId>(
toolId: Id,
parameters: Record<string, unknown>,
): PolicyToolStepOf<Id> {
const op = POLICY_OPERATIONS[toolId] as ToolOperationDescriptor<
ToolEndpoint,
PolicyParams<Id>
>;
// Wire params are untyped JSON; this is the one point they enter the typed model.
const params = op.fromApi(
parameters as unknown as ToolApiParams[ToolEndpoint],
);
return { toolId, params } as unknown as PolicyToolStepOf<Id>;
}