Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab1874b39f | ||
|
|
776749277c | ||
|
|
41b1b89fcb | ||
|
|
4d4e994562 | ||
|
|
fc210bc761 | ||
|
|
e3802c56b2 | ||
|
|
c59571f17c | ||
|
|
7cdf72c5f9 |
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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") },
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user