Portal policies (#6852)

This commit is contained in:
Reece Browne
2026-07-01 13:42:26 +01:00
committed by GitHub
parent 9d3701a585
commit 467f3a86c4
26 changed files with 1704 additions and 988 deletions
+27
View File
@@ -95,6 +95,7 @@ tasks:
vars: vars:
PORT: '{{.PORTAL_PORT}}' PORT: '{{.PORTAL_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
MOCKS: 'false'
OPEN: "true" OPEN: "true"
dev:portal:all: dev:portal:all:
@@ -117,6 +118,32 @@ tasks:
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
# Point the portal's "Editor" app switcher at the editor we spawn here. # Point the portal's "Editor" app switcher at the editor we spawn here.
EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/' EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
MOCKS: 'false'
OPEN: "true"
- task: frontend:dev
vars:
PORT: '{{.EDITOR_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
dev:portal:all:saas:
desc: "Start SaaS backend + developer portal + editor concurrently on free ports"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5174{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
deps:
- task: backend:dev:saas
vars:
PORT: '{{.BACKEND_PORT}}'
POLICIES_ENABLED: "true"
- task: frontend:dev:portal
vars:
PORT: '{{.PORTAL_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
MOCKS: 'false'
OPEN: "true" OPEN: "true"
- task: frontend:dev - task: frontend:dev
vars: vars:
@@ -212,8 +212,12 @@ export function usePolicyAutoRun(): void {
s.configured && s.configured &&
s.status === "active" && s.status === "active" &&
s.backendId && s.backendId &&
// Only auto-run on upload when the policy is set to run on upload // Only enforce in the editor when the policy includes "editor" as a source.
// (export-triggered policies enforce at export time instead). // runOn is an editor-specific parameter: "upload" fires here, "export" fires
// at export time via policyExport. Non-editor sources have their own triggers.
(!s.sources ||
s.sources.length === 0 ||
s.sources.includes("editor")) &&
(s.runOn ?? "upload") === "upload", (s.runOn ?? "upload") === "upload",
); );
for (const [categoryId, s] of active) { for (const [categoryId, s] of active) {
@@ -275,6 +279,7 @@ export function usePolicyAutoRun(): void {
// input file it ran on (needs that input's stub, still in the workspace). // input file it ran on (needs that input's stub, still in the workspace).
const outputMode = policies[run.categoryId]?.outputMode ?? "new_version"; const outputMode = policies[run.categoryId]?.outputMode ?? "new_version";
const outputName = policies[run.categoryId]?.outputName ?? ""; const outputName = policies[run.categoryId]?.outputName ?? "";
const outputNamePosition = policies[run.categoryId]?.outputNamePosition;
const parentStub = fileStubs.find((s) => (s.id as string) === run.fileId); const parentStub = fileStubs.find((s) => (s.id as string) === run.fileId);
void importOutputs(run, { void importOutputs(run, {
addFiles, addFiles,
@@ -282,6 +287,7 @@ export function usePolicyAutoRun(): void {
bumpRevision, bumpRevision,
outputMode, outputMode,
outputName, outputName,
outputNamePosition,
parentStub, parentStub,
}).finally(() => importing.current.delete(run.runId)); }).finally(() => importing.current.delete(run.runId));
} }
@@ -314,9 +320,11 @@ interface ImportContext {
bumpRevision: () => void; bumpRevision: () => void;
/** "new_file" adds the output as a separate file; "new_version" versions the input. */ /** "new_file" adds the output as a separate file; "new_version" versions the input. */
outputMode: "new_file" | "new_version"; outputMode: "new_file" | "new_version";
/** Rename rule. Empty → keep the input's filename; set → use the policy's /** Rename rule. Empty → keep the input's filename. */
* renamed output (applied server-side per the name-position setting). */
outputName: string; outputName: string;
/** Where the rename is applied: before ("prefix") or after ("suffix") the
* base filename. Defaults to "suffix" when absent. */
outputNamePosition?: "prefix" | "suffix" | "auto-number";
/** The input file's stub — required to version it; absent if it's been removed. */ /** The input file's stub — required to version it; absent if it's been removed. */
parentStub: StirlingFileStub | undefined; parentStub: StirlingFileStub | undefined;
} }
@@ -327,6 +335,20 @@ interface ImportContext {
* don't, adopt it so the poll/import effects pick it up. Server-excluded ad-hoc runs and runs we * don't, adopt it so the poll/import effects pick it up. Server-excluded ad-hoc runs and runs we
* can't map to a configured category are skipped. * can't map to a configured category are skipped.
*/ */
function applyOutputName(
inputFileName: string,
outputName: string,
position: "prefix" | "suffix" | "auto-number",
): string {
const dot = inputFileName.lastIndexOf(".");
const base = dot > 0 ? inputFileName.slice(0, dot) : inputFileName;
const ext = dot > 0 ? inputFileName.slice(dot) : "";
if (position === "suffix") return `${base}_${outputName}${ext}`;
if (position === "prefix") return `${outputName}_${base}${ext}`;
// auto-number requires dedup state not available here — fall back to suffix.
return `${base}_${outputName}${ext}`;
}
async function reconcileServerRuns( async function reconcileServerRuns(
policies: PoliciesByCategory, policies: PoliciesByCategory,
): Promise<void> { ): Promise<void> {
@@ -406,7 +428,11 @@ async function importOutputs(
// rule the backend's auto-suffixed name (e.g. "_watermarked_sanitized") would // rule the backend's auto-suffixed name (e.g. "_watermarked_sanitized") would
// otherwise rename every output. // otherwise rename every output.
const targetName = ctx.outputName const targetName = ctx.outputName
? undefined // use the run's per-output (renamed) name below ? applyOutputName(
run.fileName,
ctx.outputName,
ctx.outputNamePosition ?? "suffix",
)
: run.fileName; : run.fileName;
const settled = await Promise.allSettled( const settled = await Promise.allSettled(
pending.map(async (out) => { pending.map(async (out) => {
@@ -134,6 +134,7 @@ export function usePolicies() {
reviewerEmail: result.reviewerEmail, reviewerEmail: result.reviewerEmail,
outputMode: result.folder.outputMode, outputMode: result.folder.outputMode,
outputName: result.folder.outputName, outputName: result.folder.outputName,
outputNamePosition: result.folder.outputNamePosition,
runOn: result.folder.runOn, runOn: result.folder.runOn,
}); });
}, },
@@ -170,6 +171,7 @@ export function usePolicies() {
reviewerEmail: result.reviewerEmail, reviewerEmail: result.reviewerEmail,
outputMode: result.folder.outputMode, outputMode: result.folder.outputMode,
outputName: result.folder.outputName, outputName: result.folder.outputName,
outputNamePosition: result.folder.outputNamePosition,
runOn: result.folder.runOn, runOn: result.folder.runOn,
}); });
}, },
@@ -234,6 +236,7 @@ export function usePolicies() {
reviewerEmail: result.reviewerEmail, reviewerEmail: result.reviewerEmail,
outputMode: result.folder.outputMode, outputMode: result.folder.outputMode,
outputName: result.folder.outputName, outputName: result.folder.outputName,
outputNamePosition: result.folder.outputNamePosition,
runOn: result.folder.runOn, runOn: result.folder.runOn,
}); });
}, },
@@ -53,6 +53,7 @@ export function decodedToState(
fieldValues: decoded.fieldValues, fieldValues: decoded.fieldValues,
outputMode: decoded.folder.outputMode, outputMode: decoded.folder.outputMode,
outputName: decoded.folder.outputName, outputName: decoded.folder.outputName,
outputNamePosition: decoded.folder.outputNamePosition,
runOn: decoded.folder.runOn, runOn: decoded.folder.runOn,
folderId: localFolderId, folderId: localFolderId,
backendId: decoded.id, backendId: decoded.id,
@@ -70,6 +70,7 @@ function activeExportPolicies(): ExportPolicy[] {
s.configured && s.configured &&
s.status === "active" && s.status === "active" &&
s.backendId && s.backendId &&
(s.sources.length === 0 || s.sources.includes("editor")) &&
s.runOn === "export", s.runOn === "export",
) )
.map(([id, s]) => ({ .map(([id, s]) => ({
@@ -131,6 +131,9 @@ export interface PolicyState {
* input's filename; when set, it's applied as a prefix/suffix per the policy's * input's filename; when set, it's applied as a prefix/suffix per the policy's
* name-position setting. */ * name-position setting. */
outputName?: string; outputName?: string;
/** Whether the rename rule is applied before ("prefix") or after ("suffix")
* the base filename, or as an auto-incrementing number. */
outputNamePosition?: "prefix" | "suffix" | "auto-number";
/** When the policy runs: on "upload" or before "export". Defaults to "upload". */ /** When the policy runs: on "upload" or before "export". Defaults to "upload". */
runOn?: "upload" | "export"; runOn?: "upload" | "export";
/** /**
@@ -379,6 +379,9 @@ label = "Read mode"
consume = "Consume: process each file once" consume = "Consume: process each file once"
snapshot = "Snapshot: re-read the folder every run" snapshot = "Snapshot: re-read the folder every run"
[sources.types.editor]
label = "Editor"
[sources.types.unknown] [sources.types.unknown]
label = "Source" label = "Source"
@@ -696,6 +699,11 @@ description = "{{file}} is signed and ready to transfer. It activates one instan
title = "Policies" title = "Policies"
subtitle = "Standing automations that enforce a tool pipeline on every document. Each policy fires on upload or export, runs its tool chain, and saves the enforced version alongside the original." subtitle = "Standing automations that enforce a tool pipeline on every document. Each policy fires on upload or export, runs its tool chain, and saves the enforced version alongside the original."
[policies.offline]
title = "Backend unavailable"
description = "Your policies are saved and will appear once the connection is restored."
retry = "Retry"
[policies.status] [policies.status]
active = "Active" active = "Active"
paused = "Paused" paused = "Paused"
@@ -724,15 +732,17 @@ description = "Across active policies"
[policies.card] [policies.card]
comingSoon = "Coming soon" comingSoon = "Coming soon"
notSetUp = "Not set up" notSetUp = "Not set up"
setUp = "Set up →"
[policies.detail] [policies.detail]
title = "{{category}} policy"
meta = "Runs on {{event}} · output {{output}}"
outputAsNewFile = "as a new file" outputAsNewFile = "as a new file"
outputAsNewVersion = "as a new version" outputAsNewVersion = "as a new version"
enforces = "Enforces" enforces = "Enforces"
enforceNote = "{{scope}} · originals stay untouched, the enforced version is saved alongside." sources = "Sources"
onEveryUpload = "On every upload"
onEveryExport = "On every export"
showMore = "Show more"
showLess = "Show less"
retry = "Retry"
recentActivity = "Recent activity" recentActivity = "Recent activity"
[policies.detail.actions] [policies.detail.actions]
@@ -746,10 +756,6 @@ editSettings = "Edit settings"
title = "No activity yet" title = "No activity yet"
description = "Documents will appear here once this policy runs." description = "Documents will appear here once this policy runs."
[policies.detail.scoped]
title = "Scoped"
description = "Limited to: {{types}}"
[policies.wizard.title] [policies.wizard.title]
edit = "Edit {{category}} policy" edit = "Edit {{category}} policy"
setUp = "Set up {{category}} policy" setUp = "Set up {{category}} policy"
@@ -778,6 +784,9 @@ heading = "Settings"
[policies.wizard.sources] [policies.wizard.sources]
heading = "Sources" heading = "Sources"
loading = "Loading sources…"
emptyTitle = "No sources available"
emptyDescription = "Connect a source on the Sources page first, then attach it to a policy here."
[policies.wizard.docTypes] [policies.wizard.docTypes]
heading = "Document types" heading = "Document types"
@@ -809,9 +818,10 @@ suffix = "Suffix"
autoNumber = "Auto-number" autoNumber = "Auto-number"
placeholder = "Text to add (optional)" placeholder = "Text to add (optional)"
[policies.wizard.output.reviewerEmail] [policies.wizard.output.retries]
label = "Reviewer email" heading = "Retries"
helper = "Low-confidence enforcements are routed here for review." maxLabel = "Max retries"
delayLabel = "Retry delay (min)"
[users.summary] [users.summary]
members = "Members" members = "Members"
+7 -1
View File
@@ -39,7 +39,7 @@
* entitlement calls. It never enters the portal — the browser is the human * entitlement calls. It never enters the portal — the browser is the human
* admin and uses the Supabase JWT for SaaS reads. Don't add it here. * admin and uses the Supabase JWT for SaaS reads. Don't add it here.
*/ */
import { getStoredToken } from "@shared/auth"; import { clearStoredToken, getStoredToken } from "@shared/auth";
import { getSupabaseClient } from "@shared/auth/supabase/supabaseClient"; import { getSupabaseClient } from "@shared/auth/supabase/supabaseClient";
import { ensureSaasSupabase } from "@portal/auth/saasSupabase"; import { ensureSaasSupabase } from "@portal/auth/saasSupabase";
@@ -156,6 +156,12 @@ async function localJson<T>(
body: options.body !== undefined ? JSON.stringify(options.body) : undefined, body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
signal: options.signal, signal: options.signal,
}); });
if (res.status === 401) {
// Stale or invalid JWT — clear it so the auth provider re-initialises and
// shows the login screen rather than leaving the user stuck with a banner.
clearStoredToken();
window.dispatchEvent(new CustomEvent("jwt-available"));
}
return unwrap<T>(res); return unwrap<T>(res);
} }
+198 -39
View File
@@ -1,72 +1,173 @@
import { apiClient } from "@portal/api/http";
import type { PoliciesResponse, Policy } from "@portal/mocks/policies";
/** /**
* Policies service layer — the backend contract. * Policies service layer.
* *
* Unlike every other portal surface (which use the mock `/v1/...` base), this * The portal calls the real Stirling policy API (`/api/v1/policies`). MSW
* one calls the REAL Stirling policy API base `/api/v1/policies` so it is * intercepts these calls in dev/Storybook; dropping MSW is enough to hit the
* genuinely plug-and-play: drop MSW and these exact calls hit the live backend * live backend — no call-site changes needed.
* (PolicyController). The list response is the portal's catalogue shape; the *
* single-policy / create / delete / run calls match the backend records. * `fetchPolicies()` assembles the decorated catalogue client-side from the
* backend's flat `WirePolicy[]` + `PolicyRunView[]`, mirroring the same
* approach the editor uses for its own catalogue view.
*/ */
import { apiClient } from "@portal/api/http";
import { fromWirePolicy, toWirePolicy } from "@shared/policies/codec";
import { runsToActivity, runsToStats } from "@shared/policies/runs";
import type { PolicyDecodedState, WirePolicy } from "@shared/policies/types";
import {
POLICY_CATEGORIES,
POLICY_CONFIG,
type CatalogueEntry,
type DecoratedPolicy,
type PoliciesResponse,
type PoliciesSummary,
type PolicySetupResult,
type PolicyState,
type PolicyStatus,
} from "@portal/mocks/policies";
import type { PolicyRunView } from "@shared/policies/types";
export type { export type {
CatalogueEntry, CatalogueEntry,
DecoratedPolicy, DecoratedPolicy,
InputSpec,
OutputSpec,
PipelineStep,
PoliciesResponse, PoliciesResponse,
PoliciesSummary, PoliciesSummary,
Policy,
PolicyActivityItem,
PolicyCategory, PolicyCategory,
PolicyConfigDef, PolicyConfigDef,
PolicyDecodedState,
PolicyField, PolicyField,
PolicyFieldType, PolicyFieldType,
PolicyRowStatus, PolicyRowStatus,
PolicyRunView,
PolicySetupResult, PolicySetupResult,
PolicySource,
PolicyState, PolicyState,
PolicyStats, PolicyStats,
PolicyActivityItem,
PolicyStatus, PolicyStatus,
TriggerConfig, WirePolicy,
WireOutputOptions,
WireOutputSpec,
} from "@portal/mocks/policies"; } from "@portal/mocks/policies";
export { export {
ENDPOINT_LABELS, ENDPOINT_LABELS,
POLICY_CATEGORIES, POLICY_CATEGORIES,
POLICY_CONFIG, POLICY_CONFIG,
POLICY_DOC_TYPES, POLICY_DOC_TYPES,
POLICY_SOURCES,
TOOL_ENDPOINTS, TOOL_ENDPOINTS,
humanizeEndpoint, humanizeEndpoint,
} from "@portal/mocks/policies"; } from "@portal/mocks/policies";
/** GET /api/v1/policies — the catalogue + every configured policy. */ // Re-export the wire step type under the legacy name components depend on.
export type { WirePipelineStep as PipelineStep } from "@shared/policies/types";
// ── Client-side catalogue assembly ───────────────────────────────────────────
function decoratePolicy(
decoded: PolicyDecodedState,
runs: PolicyRunView[],
isDefault: boolean,
): DecoratedPolicy | null {
const category = POLICY_CATEGORIES.find((c) => c.id === decoded.categoryId);
const config = POLICY_CONFIG[decoded.categoryId];
if (!category || !config) return null;
const policyRuns = runs.filter((r) => r.policyId === decoded.id);
const status: PolicyStatus = decoded.enabled ? "active" : "paused";
const state: PolicyState = {
configured: true,
status,
sources: decoded.sources,
scopeTypes: decoded.scopeTypes,
reviewerEmail: decoded.reviewerEmail,
fieldValues: decoded.fieldValues,
outputMode: decoded.outputMode,
outputName: decoded.outputName,
outputNamePosition: decoded.outputNamePosition,
runOn: decoded.runOn,
maxRetries: decoded.maxRetries,
retryDelayMinutes: decoded.retryDelayMinutes,
backendId: decoded.id,
isDefault,
};
return {
category,
config,
state,
steps: decoded.steps,
stats: runsToStats(policyRuns),
activity: runsToActivity(policyRuns),
};
}
/** GET /api/v1/policies + GET /api/v1/policies/runs → assembled catalogue. */
export async function fetchPolicies(): Promise<PoliciesResponse> { export async function fetchPolicies(): Promise<PoliciesResponse> {
return apiClient.local.json<PoliciesResponse>("/api/v1/policies"); const [wirePolicies, runs] = await Promise.all([
apiClient.local.json<WirePolicy[]>("/api/v1/policies"),
apiClient.local
.json<PolicyRunView[]>("/api/v1/policies/runs")
.catch(() => [] as PolicyRunView[]),
]);
const decodedByCategory = new Map<
string,
{ decoded: PolicyDecodedState; isDefault: boolean }
>();
for (const wire of wirePolicies) {
const decoded = fromWirePolicy(wire);
if (decoded.categoryId) {
decodedByCategory.set(decoded.categoryId, { decoded, isDefault: false });
}
}
const catalogue: CatalogueEntry[] = POLICY_CATEGORIES.map((category) => {
const entry = decodedByCategory.get(category.id);
const policy = entry
? decoratePolicy(entry.decoded, runs, entry.isDefault)
: null;
return { category, config: POLICY_CONFIG[category.id], policy };
});
const active = wirePolicies.filter((p) => p.enabled).length;
const paused = wirePolicies.filter((p) => !p.enabled).length;
const enabledPolicyIds = new Set(
wirePolicies.filter((p) => p.enabled).map((p) => p.id),
);
const docsEnforced = runs.filter(
(r) =>
r.status === "COMPLETED" &&
r.policyId != null &&
enabledPolicyIds.has(r.policyId),
).length;
const summary: PoliciesSummary = {
active,
paused,
categories: POLICY_CATEGORIES.length,
docsEnforced,
};
return { summary, catalogue };
} }
/** GET /api/v1/policies/{id} — one stored policy's raw record. */ /** GET /api/v1/policies/{id} — one stored policy's raw record. */
export async function fetchPolicy(id: string): Promise<Policy> { export async function fetchPolicy(id: string): Promise<WirePolicy> {
return apiClient.local.json<Policy>( return apiClient.local.json<WirePolicy>(
`/api/v1/policies/${encodeURIComponent(id)}`, `/api/v1/policies/${encodeURIComponent(id)}`,
); );
} }
/** /**
* POST /api/v1/policies — create (blank id) or update (matched id). The backend * POST /api/v1/policies — create (blank id) or update (matched id). The
* assigns owner + team server-side and returns the stored policy with its id. * backend stamps owner + teamId server-side and returns the stored record.
*/ */
export async function savePolicy(policy: Policy): Promise<Policy> { export async function savePolicy(wire: WirePolicy): Promise<WirePolicy> {
return apiClient.local.json<Policy>("/api/v1/policies", { return apiClient.local.json<WirePolicy>("/api/v1/policies", {
method: "POST", method: "POST",
body: policy, body: wire,
}); });
} }
/** DELETE /api/v1/policies/{id} — remove a stored policy. */ /** DELETE /api/v1/policies/{id} */
export async function deletePolicy(id: string): Promise<void> { export async function deletePolicy(id: string): Promise<void> {
await apiClient.local.json<void>( await apiClient.local.json<void>(
`/api/v1/policies/${encodeURIComponent(id)}`, `/api/v1/policies/${encodeURIComponent(id)}`,
@@ -76,22 +177,80 @@ export async function deletePolicy(id: string): Promise<void> {
); );
} }
/** The async run acknowledgement: a run id to poll for status. */ // ── Wire-build helpers (so Policies.tsx doesn't need codec knowledge) ────────
export interface PolicyRunResponse {
status: boolean; const DEFAULT_RETRIES = 3;
/** The run id (poll GET /api/v1/policies/run/{id} for status). */ const DEFAULT_RETRY_DELAY = 5;
fileId: string | null;
message: string | null; // Catalogue policy bodies carry categoryId at the top level so the pipelines
// mock handler can discriminate them from raw pipeline saves on the shared
// POST /api/v1/policies endpoint. The real backend ignores unknown fields.
type CatalogueWireBody = WirePolicy & { categoryId: string };
/** Build a wire policy from a setup wizard result. */
export function buildWireFromSetup(
entry: CatalogueEntry,
result: PolicySetupResult,
enabled = true,
): CatalogueWireBody {
return {
categoryId: entry.category.id,
...toWirePolicy({
id: entry.policy?.state.backendId ?? "",
name: `${entry.category.label} Policy`,
enabled,
categoryId: entry.category.id,
sources: result.sources,
scopeTypes: result.scopeTypes,
reviewerEmail: result.reviewerEmail,
fieldValues: result.fieldValues,
runOn: result.runOn,
outputMode: result.outputMode,
outputName: result.outputName,
outputNamePosition: result.outputNamePosition,
maxRetries: result.maxRetries,
retryDelayMinutes: result.retryDelayMinutes,
steps: result.steps,
}),
};
}
/** Build a wire policy from an existing decorated policy (e.g. for pause/resume). */
export function buildWireFromState(
entry: CatalogueEntry,
policy: DecoratedPolicy,
enabled: boolean,
): CatalogueWireBody {
const s = policy.state;
return {
categoryId: entry.category.id,
...toWirePolicy({
id: s.backendId ?? "",
name: `${entry.category.label} Policy`,
enabled,
categoryId: entry.category.id,
sources: s.sources,
scopeTypes: s.scopeTypes,
reviewerEmail: s.reviewerEmail,
fieldValues: s.fieldValues,
runOn: s.runOn ?? "upload",
outputMode: s.outputMode ?? "new_version",
outputName: s.outputName ?? "",
outputNamePosition: s.outputNamePosition ?? "suffix",
maxRetries: s.maxRetries ?? DEFAULT_RETRIES,
retryDelayMinutes: s.retryDelayMinutes ?? DEFAULT_RETRY_DELAY,
steps: policy.steps,
}),
};
} }
/** /**
* POST /api/v1/policies/{id}/run — run a stored policy now. The real endpoint * POST /api/v1/policies/{id}/run — trigger a stored policy immediately. The
* is multipart (the documents to process); the portal has no files to attach, * real endpoint is multipart; the portal sends no files, relying on whatever
* so this triggers the policy on whatever the backend has queued and returns a * the backend has queued for this policy.
* run id. Runs regardless of the policy's enabled flag.
*/ */
export async function runPolicy(id: string): Promise<PolicyRunResponse> { export async function runPolicy(id: string): Promise<{ runId: string }> {
return apiClient.local.json<PolicyRunResponse>( return apiClient.local.json<{ runId: string }>(
`/api/v1/policies/${encodeURIComponent(id)}/run`, `/api/v1/policies/${encodeURIComponent(id)}/run`,
{ method: "POST" }, { method: "POST" },
); );
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Card, Chip, StatusBadge, StatTile } from "@shared/components"; import { Card, Chip, StatusBadge } from "@shared/components";
import type { CatalogueEntry } from "@portal/api/policies"; import type { CatalogueEntry } from "@portal/api/policies";
import { policyIcon } from "@portal/components/policies/policyIcons"; import { policyIcon } from "@portal/components/policies/policyIcons";
import "@portal/views/Policies.css"; import "@portal/views/Policies.css";
@@ -9,17 +9,13 @@ interface PolicyCategoryCardProps {
onOpen: (entry: CatalogueEntry) => void; onOpen: (entry: CatalogueEntry) => void;
} }
/**
* One card per policy category. Configured categories show the live status +
* stats and open the detail panel; unconfigured ones show the summary + a
* "Set up" affordance; coming-soon categories render locked and inert.
*/
export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) { export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { category, config, policy } = entry; const { category, config, policy } = entry;
const comingSoon = category.comingSoon === true; const comingSoon = category.comingSoon === true;
const openable = !comingSoon; const openable = !comingSoon;
const status = policy?.state.status; const status = policy?.state.status;
const enforces = config.rules.join(" · ");
return ( return (
<Card <Card
@@ -42,22 +38,39 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
: undefined : undefined
} }
> >
<header className="portal-policies__card-head"> <span className="portal-policies__cat-icon" aria-hidden>
<span {policyIcon(category.icon)}
className={`portal-policies__cat-icon portal-policies__cat-icon--${category.tone}`} </span>
aria-hidden
> <div className="portal-policies__card-identity">
{policyIcon(category.icon)} <h2 className="portal-policies__card-title">{category.label}</h2>
</span> {enforces && (
<div className="portal-policies__card-titles"> <span className="portal-policies__card-enforces">{enforces}</span>
<h2 className="portal-policies__card-title">{category.label}</h2> )}
<span className="portal-policies__card-blurb">{category.desc}</span> </div>
</div>
{comingSoon ? ( {comingSoon ? (
<Chip tone="neutral" size="sm"> <Chip tone="neutral" size="sm">
{t("policies.card.comingSoon")} {t("policies.card.comingSoon")}
</Chip> </Chip>
) : policy ? ( ) : policy ? (
<div className="portal-policies__card-meta">
<span className="portal-policies__card-statpair">
<span className="portal-policies__card-statval">
{policy.stats.enforced.toLocaleString()}
</span>
<span className="portal-policies__card-statlbl">
{t("policies.stats.docsEnforced")}
</span>
</span>
<span className="portal-policies__card-statpair">
<span className="portal-policies__card-statval">
{policy.stats.dataProcessed}
</span>
<span className="portal-policies__card-statlbl">
{t("policies.stats.dataProcessed")}
</span>
</span>
<StatusBadge <StatusBadge
tone={status === "paused" ? "warning" : "success"} tone={status === "paused" ? "warning" : "success"}
size="sm" size="sm"
@@ -67,45 +80,11 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
? t("policies.status.paused") ? t("policies.status.paused")
: t("policies.status.active")} : t("policies.status.active")}
</StatusBadge> </StatusBadge>
) : ( </div>
<Chip tone="blue" size="sm">
{t("policies.card.notSetUp")}
</Chip>
)}
</header>
<p className="portal-policies__card-summary">{config.summary}</p>
{policy ? (
<footer className="portal-policies__card-stats">
<StatTile
label={t("policies.stats.docsEnforced")}
value={policy.stats.enforced.toLocaleString()}
/>
<StatTile
label={t("policies.stats.dataProcessed")}
value={policy.stats.dataProcessed}
/>
<StatTile
label={t("policies.stats.activeFor")}
value={policy.stats.activeFor}
/>
</footer>
) : ( ) : (
<footer className="portal-policies__card-foot"> <Chip tone="blue" size="sm">
<div className="portal-policies__card-rules"> {t("policies.card.notSetUp")}
{config.rules.slice(0, 3).map((rule) => ( </Chip>
<Chip key={rule} tone="neutral" size="sm">
{rule}
</Chip>
))}
</div>
{!comingSoon && (
<span className="portal-policies__card-cta">
{t("policies.card.setUp")}
</span>
)}
</footer>
)} )}
</Card> </Card>
); );
@@ -12,6 +12,7 @@ const meta: Meta<typeof PolicyDetailPanel> = {
onRun: () => {}, onRun: () => {},
onTogglePause: () => {}, onTogglePause: () => {},
onDelete: () => {}, onDelete: () => {},
onRetry: () => {},
}, },
}; };
export default meta; export default meta;
@@ -43,3 +44,40 @@ export const CustomNoActivity: Story = {
}, },
}, },
}; };
/** Flagged activity items — shows retry button and error expansion. */
export const WithFlaggedItems: Story = {
args: {
policy: {
...decorateForStory("security"),
state: { ...decorateForStory("security").state, isDefault: false },
activity: [
{
doc: "Q4-Report.pdf",
action: "Low-confidence match — routed for review",
time: "2h ago",
status: "flagged",
},
{
doc: "Contract-2026.pdf",
action:
"Enforcement failed: timeout after 30s — step 2/3 (redact) did not complete within the allowed window. Check the document for unusual formatting or large embedded images.",
time: "4h ago",
status: "flagged",
},
{
doc: "Invoice-March.pdf",
action: "Enforced successfully",
time: "6h ago",
status: "enforced",
},
{
doc: "HR-Policy-v3.pdf",
action: "Processing…",
time: "just now",
status: "processing",
},
],
},
},
};
@@ -1,42 +1,102 @@
import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
Banner,
Button, Button,
Card, Card,
Chip,
EmptyState, EmptyState,
Modal, Modal,
StatTile, StatTile,
StatusBadge, StatusBadge,
} from "@shared/components"; } from "@shared/components";
import { humanizeEndpoint, type DecoratedPolicy } from "@portal/api/policies"; import {
import { policyIcon } from "@portal/components/policies/policyIcons"; humanizeEndpoint,
type DecoratedPolicy,
type PolicyActivityItem,
} from "@portal/api/policies";
import "@portal/views/Policies.css"; import "@portal/views/Policies.css";
interface PolicyDetailPanelProps { interface PolicyDetailPanelProps {
/** The configured policy being viewed, or null when closed. */
policy: DecoratedPolicy | null; policy: DecoratedPolicy | null;
/** Whether a lifecycle action (run/pause/delete) is in flight. */
busy?: boolean; busy?: boolean;
onClose: () => void; onClose: () => void;
onEdit: () => void; onEdit: () => void;
onRun: () => void; onRun?: () => void;
onTogglePause: () => void; onTogglePause: () => void;
onDelete: () => void; onDelete: () => void;
onRetry?: (item: PolicyActivityItem) => void;
} }
const ACTIVITY_TONE = { function CheckIcon() {
enforced: "success", return (
flagged: "warning", <svg
processing: "info", width="13"
} as const; height="13"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden
>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg>
);
}
function WarnIcon() {
return (
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden
>
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" />
</svg>
);
}
function SpinIcon() {
return (
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="currentColor"
className="portal-policies__activity-spin"
aria-hidden
>
<path d="M12 6v3l4-4-4-4v3c-4.42 0-8 3.58-8 8 0 1.57.46 3.03 1.24 4.26L6.7 14.8c-.45-.83-.7-1.79-.7-2.8 0-3.31 2.69-6 6-6zm6.76 1.74L17.3 9.2c.44.84.7 1.79.7 2.8 0 3.31-2.69 6-6 6v-3l-4 4 4 4v-3c4.42 0 8-3.58 8-8 0-1.57-.46-3.03-1.24-4.26z" />
</svg>
);
}
function ActivityError({ message }: { message: string }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const needsToggle = message.length > 80 || message.includes("\n");
if (!needsToggle) return <>{message}</>;
return (
<span className="portal-policies__activity-error">
<span
className={
"portal-policies__activity-error-text" +
(expanded ? "" : " portal-policies__activity-error-text--clamped")
}
>
{message}
</span>
<button
type="button"
className="portal-policies__link portal-policies__activity-error-toggle"
onClick={() => setExpanded((v) => !v)}
>
{expanded
? t("policies.detail.showLess")
: t("policies.detail.showMore")}
</button>
</span>
);
}
/**
* Narrative view for a configured policy: the enforced tool chain, recent
* activity, summary stats, and the lifecycle actions (run now, pause/resume,
* delete). Built-in (default) policies hide Delete — they're configurable but
* not deletable, matching the backend.
*/
export function PolicyDetailPanel({ export function PolicyDetailPanel({
policy, policy,
busy = false, busy = false,
@@ -45,31 +105,36 @@ export function PolicyDetailPanel({
onRun, onRun,
onTogglePause, onTogglePause,
onDelete, onDelete,
onRetry,
}: PolicyDetailPanelProps) { }: PolicyDetailPanelProps) {
const { t } = useTranslation(); const { t } = useTranslation();
if (!policy) return null; if (!policy) return null;
const { category, config, state, steps, stats, activity } = policy; const { category, config, state, steps, stats, activity } = policy;
const isPaused = state.status === "paused"; const isPaused = state.status === "paused";
const canDelete = state.isDefault !== true; const canDelete = state.isDefault !== true;
const enforceItems = steps.length > 0 ? steps.map((s) => s.operation) : [];
const enforceItems = steps.length > 0 ? steps.map((s) => s.operation) : null;
const hasEditorSource = state.sources.includes("editor");
const trigger =
state.runOn === "export"
? t("policies.detail.onEveryExport")
: t("policies.detail.onEveryUpload");
const outputLabel =
state.outputMode === "new_file"
? t("policies.detail.outputAsNewFile")
: t("policies.detail.outputAsNewVersion");
function sourceLabel(id: string) {
if (id === "editor") return t("sources.types.editor.label");
return id;
}
return ( return (
<Modal <Modal
open open
onClose={onClose} onClose={onClose}
width="lg" width="lg"
title={ title={category.label}
<span className="portal-policies__wizard-title">
<span
className={`portal-policies__cat-icon portal-policies__cat-icon--${category.tone}`}
aria-hidden
>
{policyIcon(category.icon)}
</span>
{t("policies.detail.title", { category: category.label })}
</span>
}
subtitle={config.summary}
footer={ footer={
<div className="portal-policies__detail-foot"> <div className="portal-policies__detail-foot">
{canDelete && ( {canDelete && (
@@ -84,15 +149,17 @@ export function PolicyDetailPanel({
{t("policies.detail.actions.delete")} {t("policies.detail.actions.delete")}
</Button> </Button>
)} )}
<Button {onRun && (
variant="outline" <Button
size="sm" variant="outline"
onClick={onRun} size="sm"
disabled={busy} onClick={onRun}
style={canDelete ? undefined : { marginRight: "auto" }} disabled={busy}
> style={canDelete ? undefined : { marginRight: "auto" }}
{t("policies.detail.actions.runNow")} >
</Button> {t("policies.detail.actions.runNow")}
</Button>
)}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -109,57 +176,66 @@ export function PolicyDetailPanel({
</div> </div>
} }
> >
{/* Status + trigger strip */}
<div className="portal-policies__detail-status"> <div className="portal-policies__detail-status">
<StatusBadge tone={isPaused ? "warning" : "success"} pulse={!isPaused}> <StatusBadge tone={isPaused ? "warning" : "success"} pulse={!isPaused}>
{isPaused ? t("policies.status.paused") : t("policies.status.active")} {isPaused ? t("policies.status.paused") : t("policies.status.active")}
</StatusBadge> </StatusBadge>
<span className="portal-policies__detail-meta"> {hasEditorSource && (
{t("policies.detail.meta", { <>
event: state.runOn ?? "upload", <span className="portal-policies__detail-sep" aria-hidden>
output: ·
state.outputMode === "new_file" </span>
? t("policies.detail.outputAsNewFile") <span className="portal-policies__detail-meta">{trigger}</span>
: t("policies.detail.outputAsNewVersion"), <span className="portal-policies__detail-sep" aria-hidden>
})} ·
</span>
<span className="portal-policies__detail-meta">{outputLabel}</span>
</>
)}
</div>
{/* Enforces — plain text, no pills */}
<div className="portal-policies__detail-inline">
<span className="portal-policies__detail-inline-label">
{t("policies.detail.enforces")}
</span>
<span className="portal-policies__detail-inline-value">
{enforceItems
? enforceItems.map((op, i) => (
<span key={op}>
{i > 0 && (
<span
className="portal-policies__enforce-arrow"
aria-hidden
>
{" "}
{" "}
</span>
)}
{humanizeEndpoint(op)}
</span>
))
: config.rules.join(" · ")}
</span> </span>
</div> </div>
<h3 className="portal-policies__wizard-heading"> {/* Sources */}
{t("policies.detail.enforces")} {state.sources.length > 0 && (
</h3> <div className="portal-policies__detail-inline">
<Card padding="default"> <span className="portal-policies__detail-inline-label">
{enforceItems.length > 0 ? ( {t("policies.detail.sources")}
<div className="portal-policies__enforce-flow"> </span>
{enforceItems.map((op, i) => ( <span className="portal-policies__detail-inline-value">
<span key={op} className="portal-policies__enforce-item"> {state.sources.map(sourceLabel).join(" · ")}
{i > 0 && ( </span>
<span className="portal-policies__enforce-arrow" aria-hidden> </div>
)}
</span>
)}
<Chip tone="blue" size="sm">
{humanizeEndpoint(op)}
</Chip>
</span>
))}
</div>
) : (
<div className="portal-policies__enforce-flow">
{config.rules.map((rule) => (
<Chip key={rule} tone="neutral" size="sm">
{rule}
</Chip>
))}
</div>
)}
<p className="portal-policies__enforce-note">
{t("policies.detail.enforceNote", { scope: config.scopeLabel })}
</p>
</Card>
<h3 className="portal-policies__wizard-heading"> <h3 className="portal-policies__wizard-heading">
{t("policies.detail.recentActivity")} {t("policies.detail.recentActivity")}
</h3> </h3>
{activity.length > 0 ? ( {activity.length > 0 ? (
<Card padding="none"> <Card padding="none">
{activity.map((item, i) => ( {activity.map((item, i) => (
@@ -168,20 +244,46 @@ export function PolicyDetailPanel({
className="portal-policies__activity-row" className="portal-policies__activity-row"
> >
<span <span
className={`portal-policies__activity-dot portal-policies__activity-dot--${ACTIVITY_TONE[item.status]}`} className={`portal-policies__activity-icon portal-policies__activity-icon--${
aria-hidden item.status === "flagged"
/> ? "warning"
: item.status === "processing"
? "info"
: "success"
}`}
>
{item.status === "flagged" ? (
<WarnIcon />
) : item.status === "processing" ? (
<SpinIcon />
) : (
<CheckIcon />
)}
</span>
<span className="portal-policies__activity-text"> <span className="portal-policies__activity-text">
<span className="portal-policies__activity-doc"> <span className="portal-policies__activity-doc">
{item.doc} {item.doc}
</span> </span>
<span className="portal-policies__activity-action"> <span className="portal-policies__activity-action">
{item.action} {item.status === "flagged" ? (
<ActivityError message={item.action} />
) : (
item.action
)}
</span> </span>
</span> </span>
<span className="portal-policies__activity-time"> <span className="portal-policies__activity-time">
{item.time} {item.time}
</span> </span>
{item.status === "flagged" && onRetry && (
<button
type="button"
className="portal-policies__link portal-policies__activity-retry"
onClick={() => onRetry(item)}
>
{t("policies.detail.retry")}
</button>
)}
</div> </div>
))} ))}
</Card> </Card>
@@ -209,16 +311,6 @@ export function PolicyDetailPanel({
value={stats.activeFor} value={stats.activeFor}
/> />
</Card> </Card>
{state.scopeTypes.length > 0 && (
<Banner
tone="neutral"
title={t("policies.detail.scoped.title")}
description={t("policies.detail.scoped.description", {
types: state.scopeTypes.join(", "),
})}
/>
)}
</Modal> </Modal>
); );
} }
@@ -14,14 +14,16 @@ import {
} from "@shared/components"; } from "@shared/components";
import { import {
POLICY_DOC_TYPES, POLICY_DOC_TYPES,
POLICY_SOURCES,
humanizeEndpoint, humanizeEndpoint,
type CatalogueEntry, type CatalogueEntry,
type PipelineStep, type PipelineStep,
type PolicySetupResult, type PolicySetupResult,
} from "@portal/api/policies"; } from "@portal/api/policies";
import { fetchSources } from "@portal/api/sources";
import { useAsync } from "@portal/hooks/useAsync";
import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow"; import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow";
import { policyIcon } from "@portal/components/policies/policyIcons"; import { policyIcon } from "@portal/components/policies/policyIcons";
import { sourceTypeMeta } from "@portal/components/sources/sourceTypes";
import "@portal/views/Policies.css"; import "@portal/views/Policies.css";
interface PolicySetupWizardProps { interface PolicySetupWizardProps {
@@ -59,15 +61,27 @@ function resolveFieldValues(
* round-trips); otherwise the category preset's default chain. Each preset step * round-trips); otherwise the category preset's default chain. Each preset step
* starts enabled — the user toggles tools off in the workflow. * 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"]);
function seedTools(entry: CatalogueEntry): ToolState[] { function seedTools(entry: CatalogueEntry): ToolState[] {
const source = entry.policy?.steps?.length const savedSteps = entry.policy?.steps ?? [];
? entry.policy.steps const savedByOp = new Map(savedSteps.map((s) => [s.operation, s]));
: entry.config.defaultOperations; // Always use defaultOperations as the canonical list so tools added after a
return source.map((s) => ({ // policy was first saved still appear when editing.
operation: s.operation, return entry.config.defaultOperations.map((s) => {
enabled: true, const saved = savedByOp.get(s.operation);
parameters: s.parameters, return {
})); operation: s.operation,
enabled: saved
? true
: savedSteps.length > 0
? false
: !DISABLED_BY_DEFAULT.has(s.operation),
parameters: saved?.parameters ?? s.parameters,
};
});
} }
/** /**
@@ -112,27 +126,50 @@ function PolicySetupWizardBody({
resolveFieldValues(entry), resolveFieldValues(entry),
); );
const [sources, setSources] = useState<string[]>( const [sources, setSources] = useState<string[]>(
policy?.state.sources.length ? policy.state.sources : ["editor"], policy?.state.sources ?? ["editor"],
); );
const sourcesAsync = useAsync(() => fetchSources(), []);
const availableSources = useMemo(() => {
const backendSources = (sourcesAsync.data?.sources ?? []).filter(
(s) => s.status !== "disabled",
);
const editorSource = {
id: "editor",
name: t("sources.types.editor.label"),
type: "editor",
status: "active" as const,
referenceCount: 0,
referencingPolicies: [],
config: [],
docsTotal: null,
};
return [editorSource, ...backendSources];
}, [sourcesAsync.data, t]);
const [scopeNarrow, setScopeNarrow] = useState( const [scopeNarrow, setScopeNarrow] = useState(
(policy?.state.scopeTypes.length ?? 0) > 0, (policy?.state.scopeTypes.length ?? 0) > 0,
); );
const [scopeTypes, setScopeTypes] = useState<string[]>( const [scopeTypes, setScopeTypes] = useState<string[]>(
policy?.state.scopeTypes ?? [], policy?.state.scopeTypes ?? [],
); );
const [reviewerEmail, setReviewerEmail] = useState( // TODO: replace with user-picker backed by GET /api/v1/user/users (UserSummary[]).
policy?.state.reviewerEmail ?? "you@acme.com", // Store username (which is the email in Spring Security) as reviewerEmail.
); // See UserSelector.tsx in the editor for the grouping/display pattern.
const [reviewerEmail] = useState(policy?.state.reviewerEmail ?? "");
const [outputMode, setOutputMode] = useState<"new_file" | "new_version">( const [outputMode, setOutputMode] = useState<"new_file" | "new_version">(
policy?.state.outputMode ?? "new_version", policy?.state.outputMode ?? "new_version",
); );
const [outputName, setOutputName] = useState(policy?.state.outputName ?? ""); const [outputName, setOutputName] = useState(policy?.state.outputName ?? "");
const [outputNamePosition, setOutputNamePosition] = useState< const [outputNamePosition, setOutputNamePosition] = useState<
"prefix" | "suffix" | "auto-number" "prefix" | "suffix" | "auto-number"
>("suffix"); >(policy?.state.outputNamePosition ?? "suffix");
const [runOn, setRunOn] = useState<"upload" | "export">( const [runOn, setRunOn] = useState<"upload" | "export">(
policy?.state.runOn ?? "upload", policy?.state.runOn ?? "upload",
); );
const [maxRetries, setMaxRetries] = useState(policy?.state.maxRetries ?? 3);
const [retryDelayMinutes, setRetryDelayMinutes] = useState(
policy?.state.retryDelayMinutes ?? 5,
);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -180,6 +217,8 @@ function PolicySetupWizardBody({
outputName: outputName.trim(), outputName: outputName.trim(),
outputNamePosition, outputNamePosition,
runOn, runOn,
maxRetries,
retryDelayMinutes,
steps, steps,
}); });
} catch { } catch {
@@ -197,10 +236,7 @@ function PolicySetupWizardBody({
width="lg" width="lg"
title={ title={
<span className="portal-policies__wizard-title"> <span className="portal-policies__wizard-title">
<span <span className="portal-policies__cat-icon" aria-hidden>
className={`portal-policies__cat-icon portal-policies__cat-icon--${category.tone}`}
aria-hidden
>
{policyIcon(category.icon)} {policyIcon(category.icon)}
</span> </span>
{isEdit {isEdit
@@ -272,9 +308,7 @@ function PolicySetupWizardBody({
<span className="portal-policies__tool-name"> <span className="portal-policies__tool-name">
{humanizeEndpoint(tl.operation)} {humanizeEndpoint(tl.operation)}
</span> </span>
<code className="portal-policies__tool-endpoint"> <span style={{ flex: 1 }} />
{tl.operation}
</code>
<ToggleSwitch <ToggleSwitch
size="sm" size="sm"
checked={tl.enabled} checked={tl.enabled}
@@ -315,31 +349,43 @@ function PolicySetupWizardBody({
{t("policies.wizard.sources.heading")} {t("policies.wizard.sources.heading")}
</h3> </h3>
<div className="portal-policies__sources"> <div className="portal-policies__sources">
{POLICY_SOURCES.map((src) => ( {sourcesAsync.loading && !sourcesAsync.data ? (
<button <p className="portal-policies__sources-loading">
key={src.id} {t("policies.wizard.sources.loading")}
type="button" </p>
className={ ) : availableSources.length === 1 ? (
"portal-policies__source" + <Banner
(sources.includes(src.id) tone="neutral"
? " portal-policies__source--on" title={t("policies.wizard.sources.emptyTitle")}
: "") description={t("policies.wizard.sources.emptyDescription")}
} />
onClick={() => toggleSource(src.id)} ) : (
> availableSources.map((src) => (
<span className="portal-policies__source-icon" aria-hidden> <button
{policyIcon(src.icon)} key={src.id}
</span> type="button"
<span className="portal-policies__source-text"> className={
<span className="portal-policies__source-label"> "portal-policies__source" +
{src.label} (sources.includes(src.id)
? " portal-policies__source--on"
: "")
}
onClick={() => toggleSource(src.id)}
>
<span className="portal-policies__source-icon" aria-hidden>
{sourceTypeMeta(src.type).icon}
</span> </span>
<span className="portal-policies__source-desc"> <span className="portal-policies__source-text">
{src.desc} <span className="portal-policies__source-label">
{src.name}
</span>
<span className="portal-policies__source-desc">
{src.type}
</span>
</span> </span>
</span> </button>
</button> ))
))} )}
</div> </div>
<h3 className="portal-policies__wizard-heading"> <h3 className="portal-policies__wizard-heading">
@@ -392,107 +438,130 @@ function PolicySetupWizardBody({
{t("policies.wizard.output.heading")} {t("policies.wizard.output.heading")}
</h3> </h3>
<div className="portal-policies__fields"> <div className="portal-policies__fields">
<FormField {sources.includes("editor") && (
label={t("policies.wizard.output.runOn.label")} <>
helperText={t("policies.wizard.output.runOn.helper")} <FormField
> label={t("policies.wizard.output.runOn.label")}
<Select helperText={t("policies.wizard.output.runOn.helper")}
inputSize="sm" >
value={runOn} <Select
onChange={(e) =>
setRunOn(e.target.value as "upload" | "export")
}
options={[
{
value: "upload",
label: t("policies.wizard.output.runOn.upload"),
},
{
value: "export",
label: t("policies.wizard.output.runOn.export"),
},
]}
/>
</FormField>
<FormField label={t("policies.wizard.output.outputAs.label")}>
<Select
inputSize="sm"
value={outputMode}
onChange={(e) => {
const mode = e.target.value as "new_file" | "new_version";
setOutputMode(mode);
// Auto-number only applies to separate new files.
if (
mode === "new_version" &&
outputNamePosition === "auto-number"
) {
setOutputNamePosition("suffix");
}
}}
options={[
{
value: "new_version",
label: t("policies.wizard.output.outputAs.newVersion"),
},
{
value: "new_file",
label: t("policies.wizard.output.outputAs.newFile"),
},
]}
/>
</FormField>
<FormField label={t("policies.wizard.output.filenameRule.label")}>
<div className="portal-policies__name-row">
<Select
inputSize="sm"
value={outputNamePosition}
onChange={(e) =>
setOutputNamePosition(
e.target.value as "prefix" | "suffix" | "auto-number",
)
}
options={[
{
value: "prefix",
label: t("policies.wizard.output.filenameRule.prefix"),
},
{
value: "suffix",
label: t("policies.wizard.output.filenameRule.suffix"),
},
...(outputMode === "new_file"
? [
{
value: "auto-number",
label: t(
"policies.wizard.output.filenameRule.autoNumber",
),
},
]
: []),
]}
/>
{outputNamePosition !== "auto-number" && (
<Input
inputSize="sm" inputSize="sm"
value={outputName} value={runOn}
placeholder={t( onChange={(e) =>
"policies.wizard.output.filenameRule.placeholder", setRunOn(e.target.value as "upload" | "export")
)} }
onChange={(e) => setOutputName(e.target.value)} options={[
{
value: "upload",
label: t("policies.wizard.output.runOn.upload"),
},
{
value: "export",
label: t("policies.wizard.output.runOn.export"),
},
]}
/> />
)} </FormField>
</div> <FormField label={t("policies.wizard.output.outputAs.label")}>
</FormField> <Select
<FormField inputSize="sm"
label={t("policies.wizard.output.reviewerEmail.label")} value={outputMode}
helperText={t("policies.wizard.output.reviewerEmail.helper")} onChange={(e) => {
> const mode = e.target.value as "new_file" | "new_version";
setOutputMode(mode);
// Auto-number only applies to separate new files.
if (
mode === "new_version" &&
outputNamePosition === "auto-number"
) {
setOutputNamePosition("suffix");
}
}}
options={[
{
value: "new_version",
label: t("policies.wizard.output.outputAs.newVersion"),
},
{
value: "new_file",
label: t("policies.wizard.output.outputAs.newFile"),
},
]}
/>
</FormField>
<FormField
label={t("policies.wizard.output.filenameRule.label")}
>
<div className="portal-policies__name-row">
<Select
inputSize="sm"
value={outputNamePosition}
onChange={(e) =>
setOutputNamePosition(
e.target.value as "prefix" | "suffix" | "auto-number",
)
}
options={[
{
value: "prefix",
label: t(
"policies.wizard.output.filenameRule.prefix",
),
},
{
value: "suffix",
label: t(
"policies.wizard.output.filenameRule.suffix",
),
},
...(outputMode === "new_file"
? [
{
value: "auto-number",
label: t(
"policies.wizard.output.filenameRule.autoNumber",
),
},
]
: []),
]}
/>
{outputNamePosition !== "auto-number" && (
<Input
inputSize="sm"
value={outputName}
placeholder={t(
"policies.wizard.output.filenameRule.placeholder",
)}
onChange={(e) => setOutputName(e.target.value)}
/>
)}
</div>
</FormField>
</>
)}
{/* TODO: reviewer user-picker goes here */}
<h4 className="portal-policies__wizard-subheading">
{t("policies.wizard.output.retries.heading")}
</h4>
<FormField label={t("policies.wizard.output.retries.maxLabel")}>
<Input <Input
inputSize="sm" inputSize="sm"
type="email" type="number"
value={reviewerEmail} value={String(maxRetries)}
onChange={(e) => setReviewerEmail(e.target.value)} onChange={(e) =>
setMaxRetries(Math.max(0, Number(e.target.value) || 0))
}
/>
</FormField>
<FormField label={t("policies.wizard.output.retries.delayLabel")}>
<Input
inputSize="sm"
type="number"
value={String(retryDelayMinutes)}
onChange={(e) =>
setRetryDelayMinutes(Math.max(0, Number(e.target.value) || 0))
}
/> />
</FormField> </FormField>
</div> </div>
@@ -3,38 +3,53 @@
* policy built straight from the catalogue + seed data, so stories render the * policy built straight from the catalogue + seed data, so stories render the
* same shapes the MSW handlers serve without standing up the whole API. * same shapes the MSW handlers serve without standing up the whole API.
*/ */
import { fromWirePolicy } from "@shared/policies/codec";
import { runsToActivity, runsToStats } from "@shared/policies/runs";
import { import {
POLICY_CATEGORIES, POLICY_CATEGORIES,
POLICY_CONFIG, POLICY_CONFIG,
seedRuntime, seedPolicies,
seedPolicyRuns,
type DecoratedPolicy, type DecoratedPolicy,
type PolicyState,
} from "@portal/mocks/policies"; } from "@portal/mocks/policies";
export { POLICY_CATEGORIES, POLICY_CONFIG }; export { POLICY_CATEGORIES, POLICY_CONFIG };
/** A decorated, active policy for a category, mirroring the handler's decorate(). */ /** A decorated, active policy for a category, mirroring fetchPolicies() assembly. */
export function decorateForStory(categoryId: string): DecoratedPolicy { export function decorateForStory(categoryId: string): DecoratedPolicy {
const category = POLICY_CATEGORIES.find((c) => c.id === categoryId)!; const category = POLICY_CATEGORIES.find((c) => c.id === categoryId)!;
const config = POLICY_CONFIG[categoryId]; const config = POLICY_CONFIG[categoryId];
const rt = seedRuntime().pol_security_default;
// Use the seeded security policy for any category (story only needs the shape).
const wire = seedPolicies()[0];
const decoded = fromWirePolicy(wire);
const allRuns = seedPolicyRuns();
const policyRuns = allRuns.filter((r) => r.policyId === wire.id);
const state: PolicyState = {
configured: true,
status: decoded.enabled ? "active" : "paused",
sources: decoded.sources,
scopeTypes: decoded.scopeTypes,
reviewerEmail: decoded.reviewerEmail,
fieldValues: decoded.fieldValues,
outputMode: decoded.outputMode,
outputName: decoded.outputName,
outputNamePosition: decoded.outputNamePosition,
runOn: decoded.runOn,
maxRetries: decoded.maxRetries,
retryDelayMinutes: decoded.retryDelayMinutes,
backendId: wire.id,
isDefault: true,
};
return { return {
category, category,
config, config,
state: { state,
configured: true, steps: decoded.steps,
status: "active", stats: runsToStats(policyRuns),
sources: ["editor"], activity: runsToActivity(policyRuns),
scopeTypes: [],
reviewerEmail: rt.reviewerEmail,
fieldValues: {},
outputMode: "new_version",
outputName: "",
runOn: "upload",
backendId: "pol_story",
isDefault: true,
},
steps: config.defaultOperations,
stats: rt.stats,
activity: rt.activity,
}; };
} }
@@ -17,6 +17,7 @@ export interface SourceTypeMeta {
const SOURCE_TYPE_META: Record<string, SourceTypeMeta> = { const SOURCE_TYPE_META: Record<string, SourceTypeMeta> = {
folder: { labelKey: "sources.types.folder.label", icon: "⛁", tone: "blue" }, folder: { labelKey: "sources.types.folder.label", icon: "⛁", tone: "blue" },
editor: { labelKey: "sources.types.editor.label", icon: "✏", tone: "green" },
}; };
const UNKNOWN_TYPE_META: SourceTypeMeta = { const UNKNOWN_TYPE_META: SourceTypeMeta = {
+36 -126
View File
@@ -1,39 +1,35 @@
import { http, HttpResponse, delay } from "msw"; import { http, HttpResponse, delay } from "msw";
import { import {
POLICY_CATEGORIES,
POLICY_CONFIG,
seedPolicies, seedPolicies,
seedRuntime, seedPolicyRuns,
emptyRuntime, type WirePolicy,
type CatalogueEntry,
type DecoratedPolicy,
type PoliciesResponse,
type PoliciesSummary,
type Policy,
type PolicyRowStatus,
type PolicyRuntime,
type PolicyState,
} from "@portal/mocks/policies"; } from "@portal/mocks/policies";
import type { PolicyRunView } from "@shared/policies/types";
/** /**
* The portal exercises the REAL policy API base — `/api/v1/policies`, NOT the * The portal exercises the REAL policy API base — `/api/v1/policies`, NOT the
* portal's usual `/v1/...` — so this surface is plug-and-play against the live * portal's usual `/v1/...` — so this surface is plug-and-play against the live
* backend (drop MSW and the same calls hit Stirling). These handlers mutate an * backend (drop MSW and the same calls hit Stirling).
* in-memory store, so create/delete/run behave like a real backend within a *
* session (see the notifications handler for the same stateful pattern). * These handlers speak the backend's actual wire contract:
* - GET /api/v1/policies → WirePolicy[]
* - GET /api/v1/policies/runs → PolicyRunView[]
* - POST /api/v1/policies → WirePolicy (create / update)
* - DELETE /api/v1/policies/:id → 204
*
* The decorated catalogue (summary, category grouping, stats) is assembled
* client-side in api/policies.ts#fetchPolicies(), mirroring the real backend.
*/ */
/** Configured policies, keyed by backend id (the source of truth). */ let store: WirePolicy[] = seedPolicies();
let store: Policy[] = seedPolicies(); let runs: PolicyRunView[] = seedPolicyRuns();
/** Runtime extras the wire record doesn't carry (scope, stats, activity). */
let runtime: Record<string, PolicyRuntime> = seedRuntime();
export function resetPoliciesStore( export function resetPoliciesStore(
seed?: Policy[], seed?: WirePolicy[],
seedRt?: Record<string, PolicyRuntime>, seedRuns?: PolicyRunView[],
): void { ): void {
store = seed ? [...seed] : seedPolicies(); store = seed ? [...seed] : seedPolicies();
runtime = seedRt ? { ...seedRt } : seedRuntime(); runs = seedRuns ? [...seedRuns] : seedPolicyRuns();
} }
let idCounter = 0; let idCounter = 0;
@@ -42,78 +38,21 @@ function nextId(categoryId: string): string {
return `pol_${categoryId}_${Date.now().toString(36)}_${idCounter}`; return `pol_${categoryId}_${Date.now().toString(36)}_${idCounter}`;
} }
/** Derive the display status from the wire `enabled` flag. */ function categoryId(wire: WirePolicy): string {
function rowStatus(policy: Policy): PolicyRowStatus { return (wire.output?.options?.categoryId as string | undefined) ?? "";
return policy.enabled ? "active" : "paused";
}
/** Build the decorated runtime view the catalogue/detail consumes. */
function decorate(policy: Policy): DecoratedPolicy | null {
const category = POLICY_CATEGORIES.find((c) => c.id === policy.categoryId);
const config = POLICY_CONFIG[policy.categoryId];
if (!category || !config) return null;
const rt = runtime[policy.id] ?? emptyRuntime();
const status = rowStatus(policy);
const state: PolicyState = {
configured: true,
status: status === "paused" ? "paused" : "active",
sources: policy.sources.map((s) => s.source),
scopeTypes: rt.scopeTypes,
reviewerEmail: rt.reviewerEmail,
fieldValues: rt.fieldValues,
outputMode: policy.output.mode,
outputName: policy.output.name,
runOn: policy.trigger?.event ?? "upload",
backendId: policy.id,
isDefault: rt.isDefault,
};
return {
category,
config,
state,
steps: policy.steps,
stats: rt.stats,
activity: rt.activity,
};
}
/** The full catalogue response: every category, each with its policy (or null). */
function buildResponse(): PoliciesResponse {
const byCategory = new Map<string, Policy>();
for (const p of store) byCategory.set(p.categoryId, p);
const catalogue: CatalogueEntry[] = POLICY_CATEGORIES.map((category) => {
const policy = byCategory.get(category.id);
return {
category,
config: POLICY_CONFIG[category.id],
policy: policy ? decorate(policy) : null,
};
});
const active = store.filter((p) => p.enabled).length;
const paused = store.filter((p) => !p.enabled).length;
const docsEnforced = store
.filter((p) => p.enabled)
.reduce((sum, p) => sum + (runtime[p.id]?.stats.enforced ?? 0), 0);
const summary: PoliciesSummary = {
active,
paused,
categories: POLICY_CATEGORIES.length,
docsEnforced,
};
return { summary, catalogue };
} }
export const policiesHandlers = [ export const policiesHandlers = [
// List — the catalogue (categories + configs + configured policies).
http.get("/api/v1/policies", async () => { http.get("/api/v1/policies", async () => {
await delay(120); await delay(120);
return HttpResponse.json(buildResponse()); return HttpResponse.json(store);
}),
http.get("/api/v1/policies/runs", async () => {
await delay(120);
return HttpResponse.json(runs);
}), }),
// Get one stored policy by id (the raw wire record).
http.get("/api/v1/policies/:id", async ({ params }) => { http.get("/api/v1/policies/:id", async ({ params }) => {
await delay(120); await delay(120);
const policy = store.find((p) => p.id === params.id); const policy = store.find((p) => p.id === params.id);
@@ -121,17 +60,17 @@ export const policiesHandlers = [
return HttpResponse.json(policy); return HttpResponse.json(policy);
}), }),
// Create or update — a blank id is assigned (create) or matched (update). // Create or update — one policy per category: a create for a category that
// One policy per category: a create for a category that already has one // already has one replaces it, matching the editor's contract.
// replaces it, matching the editor's "one policy per category, ever".
http.post("/api/v1/policies", async ({ request }) => { http.post("/api/v1/policies", async ({ request }) => {
await delay(120); await delay(120);
const incoming = (await request.json()) as Policy; const incoming = (await request.json()) as WirePolicy;
const catId = categoryId(incoming);
const existing = incoming.id const existing = incoming.id
? store.find((p) => p.id === incoming.id) ? store.find((p) => p.id === incoming.id)
: store.find((p) => p.categoryId === incoming.categoryId); : store.find((p) => categoryId(p) === catId);
const id = existing?.id ?? nextId(incoming.categoryId); const id = existing?.id ?? nextId(catId);
const saved: Policy = { const saved: WirePolicy = {
...incoming, ...incoming,
id, id,
owner: existing?.owner ?? "you@acme.com", owner: existing?.owner ?? "you@acme.com",
@@ -139,45 +78,16 @@ export const policiesHandlers = [
store = existing store = existing
? store.map((p) => (p.id === id ? saved : p)) ? store.map((p) => (p.id === id ? saved : p))
: [...store, saved]; : [...store, saved];
// Seed runtime for a brand-new policy so the detail panel has somewhere to
// read from; an update keeps whatever runtime it already had.
if (!runtime[id]) runtime[id] = emptyRuntime();
return HttpResponse.json(saved); return HttpResponse.json(saved);
}), }),
// Delete a stored policy by id.
http.delete("/api/v1/policies/:id", async ({ params }) => { http.delete("/api/v1/policies/:id", async ({ params }) => {
await delay(120); await delay(120);
const id = String(params.id); const id = String(params.id);
const existed = store.some((p) => p.id === id); if (!store.some((p) => p.id === id))
if (!existed) return new HttpResponse(null, { status: 404 }); return new HttpResponse(null, { status: 404 });
store = store.filter((p) => p.id !== id); store = store.filter((p) => p.id !== id);
delete runtime[id]; runs = runs.filter((r) => r.policyId !== id);
return new HttpResponse(null, { status: 204 }); return new HttpResponse(null, { status: 204 });
}), }),
// Run a stored policy now. The real endpoint is multipart (files) and returns
// a run id; the portal has no files, so the mock just acknowledges with a run
// id and nudges the activity feed so the run is visible.
http.post("/api/v1/policies/:id/run", async ({ params }) => {
await delay(120);
const id = String(params.id);
const policy = store.find((p) => p.id === id);
if (!policy) return new HttpResponse(null, { status: 404 });
const rt = runtime[id] ?? emptyRuntime();
runtime[id] = {
...rt,
activity: [
{
doc: "manual-run.pdf",
action: "Enforcing…",
time: "just now",
status: "processing",
},
...rt.activity,
],
};
const runId = `run_${Date.now().toString(36)}`;
return HttpResponse.json({ status: true, fileId: runId, message: null });
}),
]; ];
+110 -278
View File
@@ -1,200 +1,84 @@
/** /**
* Policies fixtures and the canonical TS model the portal shares with them. * Policies fixtures and the canonical TS model the portal shares with them.
* *
* The model mirrors the editor + backend policy contract so the portal's * Wire types (`WirePolicy`, `WirePipelineStep`) come from the shared codec
* "set up a policy" flow is plug-and-play against the real `/api/v1/policies` * layer and match the backend record exactly. Catalogue and UI types
* API. A policy is a stored automation: an ordered chain of tool steps (each * (`PolicyCategory`, `PolicyConfigDef`, `PolicyState`, …) are portal-only:
* step's `operation` is a Stirling endpoint path) plus an output destination, * the backend has no "category" concept — `categoryId` rides in
* fired automatically by a trigger (editor upload/export) over a set of * `output.options`. The catalogue assembles client-side in `api/policies.ts`
* sources. The catalogue groups policies by category, each category carrying a * from the decoded wire records + these static definitions.
* `PolicyConfigDef` (summary, rules, fields, default tool chain) the setup flow
* builds from.
* *
* The wire types (`Policy`, `PipelineStep`) match the backend records exactly; * api/policies.ts re-exports everything; components never reach in here.
* the catalogue + decorated state shapes (`PolicyConfigDef`, `PolicyField`,
* `PolicyState`, …) are lifted from the editor's `types/policies.ts`, with
* ReactNode icons replaced by string icon keys (the portal renders its own).
*
* api/policies.ts re-exports these types; the MSW handlers serve the fixture
* data over intercepted apiClient.local.json() calls. Components never reach in here.
*/ */
/* ──────────────────────────────────────────────────────────────────────── */ import type { WirePipelineStep, WirePolicy } from "@shared/policies/types";
/* Backend wire model — matches Policy.java / PipelineStep.java exactly */ import type { PolicyRunView } from "@shared/policies/types";
/* ──────────────────────────────────────────────────────────────────────── */
/** export type {
* A single tool invocation in a policy's pipeline. `operation` is a Stirling PolicyActivityItem,
* endpoint path (e.g. `/api/v1/security/auto-redact`); `parameters` are the PolicyDecodedState,
* scalar form fields that endpoint accepts. `fileParameters` binds a tool's PolicyRunStatus,
* named file field to an asset key in a run's supporting-file store. PolicyRunView,
*/ PolicyStats,
export interface PipelineStep { WireOutputOptions,
operation: string; WireOutputSpec,
parameters: Record<string, unknown>; WirePipelineStep,
fileParameters?: Record<string, string>; WirePolicy,
} } from "@shared/policies/types";
/** When a policy fires automatically. A null trigger means manual-only. */
export interface TriggerConfig {
/** The editor event the policy runs on. */
event: "upload" | "export";
}
/** Where a policy's documents come from (a connected source). */
export interface InputSpec {
/** Source id from {@link POLICY_SOURCES}. */
source: string;
}
/** How a run's result is delivered. */
export interface OutputSpec {
/** A separate new file, or a new version of the input the policy ran on. */
mode: "new_file" | "new_version";
/** Rename rule for the output; empty keeps the input filename. */
name: string;
namePosition: "prefix" | "suffix" | "auto-number";
}
/**
* The stored policy record — the exact JSON body the backend returns from
* `GET /api/v1/policies` and accepts on `POST /api/v1/policies`. The portal
* decorates this with catalogue + runtime data for display (see {@link decorate}).
*/
export interface Policy {
/** Blank on create; the backend assigns one and returns it. */
id: string;
name: string;
/** Server-assigned owner; the client never forges it. */
owner?: string;
/** Whether the trigger fires automatically. Pausing flips this. */
enabled: boolean;
trigger: TriggerConfig | null;
sources: InputSpec[];
steps: PipelineStep[];
output: OutputSpec;
/** The category this policy belongs to. Drives catalogue grouping. */
categoryId: string;
}
/* ──────────────────────────────────────────────────────────────────────── */ /* ──────────────────────────────────────────────────────────────────────── */
/* Catalogue model — lifted from editor types/policies.ts */ /* Catalogue model — portal-specific (lifted from editor types/policies.ts) */
/* ──────────────────────────────────────────────────────────────────────── */ /* ──────────────────────────────────────────────────────────────────────── */
export type PolicyStatus = "default" | "active" | "paused"; export type PolicyStatus = "active" | "paused";
/** Derived display status for a card/detail. */
export type PolicyRowStatus = "active" | "paused" | "setup"; export type PolicyRowStatus = "active" | "paused" | "setup";
/** A configurable field within a policy's settings. */
export type PolicyFieldType = "toggle" | "select" | "chips" | "text"; export type PolicyFieldType = "toggle" | "select" | "chips" | "text";
export interface PolicyField { export interface PolicyField {
label: string; label: string;
key: string; key: string;
type: PolicyFieldType; type: PolicyFieldType;
/** Default value: boolean (toggle), string (select/text), string[] (chips). */
value: boolean | string | string[]; value: boolean | string | string[];
/** Options for select/chips. */
options?: string[]; options?: string[];
} }
/**
* Static definition of a policy category. The editor's `icon: ReactNode` is
* replaced by a string `icon` key the portal resolves to its own glyph.
*/
export interface PolicyCategory { export interface PolicyCategory {
id: string; id: string;
label: string; label: string;
/** Icon key the portal renders (not a component — the portal owns glyphs). */
icon: string; icon: string;
/** Visual tone for the category's icon chip. */
tone: "neutral" | "blue" | "purple" | "green" | "amber" | "red"; tone: "neutral" | "blue" | "purple" | "green" | "amber" | "red";
/** Long description shown in the setup flow. */
desc: string; desc: string;
/** Drives the "Set up Classification" affordance (doc-type narrowing). */
providesClassification?: boolean; providesClassification?: boolean;
/** Locked "Coming soon" — can't be opened or configured. */
comingSoon?: boolean; comingSoon?: boolean;
} }
/** The narrative + field configuration backing a category. */
export interface PolicyConfigDef { export interface PolicyConfigDef {
/** One-line summary of what the policy enforces. */
summary: string; summary: string;
/** Pipeline-like rule chips shown in the "Enforces" section. */
rules: string[]; rules: string[];
/** Human label for the scope this policy applies to. */
scopeLabel: string; scopeLabel: string;
/** Editable policy-level settings fields. */
fields: PolicyField[]; fields: PolicyField[];
/** defaultOperations: WirePipelineStep[];
* The preset pipeline a new policy is seeded with — the real, editable tool
* steps (each `operation` is a Stirling endpoint path, matching the backend's
* PipelineStep). The setup flow starts from these.
*/
defaultOperations: PipelineStep[];
} }
/** A source a policy can run over (setup "Sources" step). */
export interface PolicySource {
id: string;
label: string;
desc: string;
/** Icon key the portal renders. */
icon: string;
}
/** Three-up summary stats shown at the foot of a configured policy's detail. */
export interface PolicyStats {
/** Documents enforced. */
enforced: number;
/** Human-formatted data-processed figure, e.g. "2.3 GB". */
dataProcessed: string;
/** Human-formatted active-for figure, e.g. "12d", or "—" when idle. */
activeFor: string;
}
/** An entry in a policy's recent-activity feed. */
export interface PolicyActivityItem {
/** Document the policy acted on. */
doc: string;
/** What the policy did, e.g. "Redacted 4 PII matches • 2 pages". */
action: string;
/** Relative timestamp, e.g. "2h ago". */
time: string;
/** "enforced" (clean), "flagged" (needs review), "processing" (running). */
status: "enforced" | "flagged" | "processing";
}
/**
* The collected settings the setup flow gathers and the detail panel reads —
* the editor's `PolicyState`, minus the local-cache bookkeeping (folderId etc).
*/
export interface PolicyState { export interface PolicyState {
configured: boolean; configured: boolean;
status: PolicyStatus; status: PolicyStatus;
/** Selected sources (ids from {@link POLICY_SOURCES}). */
sources: string[]; sources: string[];
/** When non-empty, narrows the policy to these document types. */
scopeTypes: string[]; scopeTypes: string[];
/** Email low-confidence enforcements are routed to. */
reviewerEmail: string; reviewerEmail: string;
/** Saved field values, keyed by field key (overrides the definition default). */
fieldValues: Record<string, boolean | string | string[]>; fieldValues: Record<string, boolean | string | string[]>;
/** How a run's output is delivered. Defaults to "new_version". */
outputMode?: "new_file" | "new_version"; outputMode?: "new_file" | "new_version";
/** Rename rule for the output. Empty keeps the input filename. */
outputName?: string; outputName?: string;
/** When the policy runs. Defaults to "upload". */ outputNamePosition?: "prefix" | "suffix" | "auto-number";
runOn?: "upload" | "export"; runOn?: "upload" | "export";
/** Backend record id once persisted; used to update/delete/run it. */ maxRetries?: number;
retryDelayMinutes?: number;
backendId?: string; backendId?: string;
/** A shipped catalogue policy (configurable but not deletable). */
isDefault?: boolean; isDefault?: boolean;
} }
/** What the setup flow hands back on submit — collected settings + built steps. */
export interface PolicySetupResult { export interface PolicySetupResult {
fieldValues: Record<string, boolean | string | string[]>; fieldValues: Record<string, boolean | string | string[]>;
sources: string[]; sources: string[];
@@ -204,57 +88,40 @@ export interface PolicySetupResult {
outputName: string; outputName: string;
outputNamePosition: "prefix" | "suffix" | "auto-number"; outputNamePosition: "prefix" | "suffix" | "auto-number";
runOn: "upload" | "export"; runOn: "upload" | "export";
/** The configured tool chain as backend pipeline steps. */ maxRetries: number;
steps: PipelineStep[]; retryDelayMinutes: number;
steps: WirePipelineStep[];
} }
/**
* A configured policy as the catalogue view consumes it: the wire record plus
* the catalogue's category/config and derived runtime data. The handlers build
* this from the in-memory store + fixtures.
*/
export interface DecoratedPolicy { export interface DecoratedPolicy {
category: PolicyCategory; category: PolicyCategory;
config: PolicyConfigDef; config: PolicyConfigDef;
state: PolicyState; state: PolicyState;
/** The policy's configured steps (drives the detail "Enforces" flow). */ steps: WirePipelineStep[];
steps: PipelineStep[]; stats: import("@shared/policies/types").PolicyStats;
stats: PolicyStats; activity: import("@shared/policies/types").PolicyActivityItem[];
activity: PolicyActivityItem[];
} }
/** Catalogue strip totals shown above the cards. */
export interface PoliciesSummary { export interface PoliciesSummary {
/** Policies currently active (enabled). */
active: number; active: number;
/** Policies configured but paused. */
paused: number; paused: number;
/** Categories available to configure. */
categories: number; categories: number;
/** Documents enforced across all active policies. */
docsEnforced: number; docsEnforced: number;
} }
/** The `GET /api/v1/policies` response, in the portal's catalogue shape. */
export interface PoliciesResponse { export interface PoliciesResponse {
summary: PoliciesSummary; summary: PoliciesSummary;
/** Every catalogue category, each with its definition + (optional) state. */
catalogue: CatalogueEntry[]; catalogue: CatalogueEntry[];
} }
/** One catalogue row: a category, its definition, and its current state. */
export interface CatalogueEntry { export interface CatalogueEntry {
category: PolicyCategory; category: PolicyCategory;
config: PolicyConfigDef; config: PolicyConfigDef;
/** The configured policy's runtime view, or null when not yet set up. */
policy: DecoratedPolicy | null; policy: DecoratedPolicy | null;
} }
/* ──────────────────────────────────────────────────────────────────────── */ /* ──────────────────────────────────────────────────────────────────────── */
/* Tool → endpoint registry */ /* Tool → endpoint registry */
/* Maps a frontend tool id to its Stirling endpoint path. The setup flow's */
/* pipeline steps carry endpoint paths (the backend's PipelineStep contract), */
/* so this is the seam that keeps the preset chains plug-and-play. */
/* ──────────────────────────────────────────────────────────────────────── */ /* ──────────────────────────────────────────────────────────────────────── */
export const TOOL_ENDPOINTS: Record<string, string> = { export const TOOL_ENDPOINTS: Record<string, string> = {
@@ -266,7 +133,6 @@ export const TOOL_ENDPOINTS: Record<string, string> = {
compress: "/api/v1/misc/compress-pdf", compress: "/api/v1/misc/compress-pdf",
}; };
/** A friendly label for an endpoint path (for the detail "Enforces" chips). */
export const ENDPOINT_LABELS: Record<string, string> = { export const ENDPOINT_LABELS: Record<string, string> = {
"/api/v1/security/auto-redact": "Redact PII", "/api/v1/security/auto-redact": "Redact PII",
"/api/v1/security/sanitize-pdf": "Remove JavaScript", "/api/v1/security/sanitize-pdf": "Remove JavaScript",
@@ -276,7 +142,6 @@ export const ENDPOINT_LABELS: Record<string, string> = {
"/api/v1/misc/compress-pdf": "Compress", "/api/v1/misc/compress-pdf": "Compress",
}; };
/** "/api/v1/security/auto-redact" → "Auto Redact" — fallback humanisation. */
export function humanizeEndpoint(path: string): string { export function humanizeEndpoint(path: string): string {
if (ENDPOINT_LABELS[path]) return ENDPOINT_LABELS[path]; if (ENDPOINT_LABELS[path]) return ENDPOINT_LABELS[path];
const last = path.split("/").filter(Boolean).pop() ?? path; const last = path.split("/").filter(Boolean).pop() ?? path;
@@ -287,15 +152,12 @@ export function humanizeEndpoint(path: string): string {
} }
/* ──────────────────────────────────────────────────────────────────────── */ /* ──────────────────────────────────────────────────────────────────────── */
/* Catalogue definitions — categories, configs, sources, doc types */ /* Catalogue definitions */
/* Modelled on the editor's policyDefinitions. PII redact regexes are the */
/* precise patterns the /auto-redact endpoint matches (wordsToRedact). */
/* ──────────────────────────────────────────────────────────────────────── */ /* ──────────────────────────────────────────────────────────────────────── */
/** PII regexes seeded into a Security policy's redact step (SSN + cards). */
const DEFAULT_PII_PATTERNS: string[] = [ const DEFAULT_PII_PATTERNS: string[] = [
"\\b(?!000|666|9\\d{2})\\d{3}([- ])(?!00)\\d{2}\\1(?!0000)\\d{4}\\b", // SSN "\\b(?!000|666|9\\d{2})\\d{3}([- ])(?!00)\\d{2}\\1(?!0000)\\d{4}\\b",
"\\b(?:4\\d{12}(?:\\d{3})?|5[1-5]\\d{14}|3[47]\\d{13}|6(?:011|5\\d{2})\\d{12})\\b", // cards "\\b(?:4\\d{12}(?:\\d{3})?|5[1-5]\\d{14}|3[47]\\d{13}|6(?:011|5\\d{2})\\d{12})\\b",
]; ];
export const POLICY_CATEGORIES: PolicyCategory[] = [ export const POLICY_CATEGORIES: PolicyCategory[] = [
@@ -371,10 +233,8 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
security: { security: {
summary: summary:
"Detects and redacts PII, strips active content (JavaScript), and watermarks documents.", "Detects and redacts PII, strips active content (JavaScript), and watermarks documents.",
rules: ["Redact PII", "Remove JavaScript"], rules: ["Redact PII", "Remove JavaScript", "Watermark"],
scopeLabel: "All documents", scopeLabel: "All documents",
// Default chain: redact PII (flattened to image so text is truly removed) +
// strip JavaScript. Watermark is offered in the designer but off by default.
defaultOperations: [ defaultOperations: [
{ {
operation: TOOL_ENDPOINTS.redact, operation: TOOL_ENDPOINTS.redact,
@@ -395,9 +255,14 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
removeFonts: false, removeFonts: false,
}, },
}, },
{
operation: TOOL_ENDPOINTS.watermark,
// convertPDFToImage bakes the watermark in so it can't be stripped
parameters: {
convertPDFToImage: true,
},
},
], ],
// The tool chain is configured per-tool in the designer (redact / sanitize /
// watermark); no separate policy-level fields.
fields: [], fields: [],
}, },
compliance: { compliance: {
@@ -482,45 +347,6 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
}, },
}; };
export const POLICY_SOURCES: PolicySource[] = [
{
id: "editor",
label: "Editor",
desc: "Documents you save or export in Stirling",
icon: "file",
},
{
id: "device",
label: "Entire device",
desc: "All PDFs on this machine, retroactively",
icon: "device",
},
{
id: "sharepoint",
label: "SharePoint",
desc: "Connected SharePoint libraries",
icon: "globe",
},
{
id: "dropbox",
label: "Dropbox",
desc: "Connected Dropbox folders",
icon: "cloud",
},
{
id: "gmail",
label: "Gmail",
desc: "PDF attachments in email",
icon: "mail",
},
{
id: "gdrive",
label: "Google Drive",
desc: "Connected Drive folders",
icon: "folder",
},
];
export const POLICY_DOC_TYPES: string[] = [ export const POLICY_DOC_TYPES: string[] = [
"Contracts", "Contracts",
"Invoices", "Invoices",
@@ -533,80 +359,86 @@ export const POLICY_DOC_TYPES: string[] = [
]; ];
/* ──────────────────────────────────────────────────────────────────────── */ /* ──────────────────────────────────────────────────────────────────────── */
/* Seed policies — a few configured policies in the wire shape, so the store */ /* Seed data — real backend wire format */
/* behaves like a backend that already has policies set up. */
/* ──────────────────────────────────────────────────────────────────────── */ /* ──────────────────────────────────────────────────────────────────────── */
/** The shipped default policies the handlers seed the store with. */ export function seedPolicies(): WirePolicy[] {
export function seedPolicies(): Policy[] {
return [ return [
{ {
id: "pol_security_default", id: "pol_security_default",
name: "Security Policy", name: "Security Policy",
owner: "security@acme.com", owner: "security@acme.com",
enabled: true, enabled: true,
trigger: { event: "upload" }, trigger: null,
sources: [{ source: "editor" }],
steps: POLICY_CONFIG.security.defaultOperations, steps: POLICY_CONFIG.security.defaultOperations,
output: { mode: "new_version", name: "", namePosition: "suffix" }, output: {
categoryId: "security", type: "inline",
options: {
runOn: "upload",
mode: "new_version",
name: "",
position: "suffix",
maxRetries: 3,
retryDelayMinutes: 5,
categoryId: "security",
sources: ["src-claims"],
scopeTypes: [],
reviewerEmail: "security@acme.com",
fieldValues: {},
},
},
}, },
]; ];
} }
/** const NOW = Date.now();
* Per-policy runtime extras keyed by policy id — the parts the wire record const M = 60000;
* doesn't carry (collected field values, scope, derived stats + activity). const H = 3600000;
* In a real backend these would be derived server-side from the user's files. const D = 86400000;
*/
export interface PolicyRuntime {
scopeTypes: string[];
reviewerEmail: string;
fieldValues: Record<string, boolean | string | string[]>;
stats: PolicyStats;
activity: PolicyActivityItem[];
isDefault?: boolean;
}
export function seedRuntime(): Record<string, PolicyRuntime> { /** Seed `PolicyRunView` records that drive the activity feed + stats. */
return { export function seedPolicyRuns(): PolicyRunView[] {
pol_security_default: { return [
scopeTypes: [], {
reviewerEmail: "security@acme.com", runId: "run_001",
fieldValues: {}, policyId: "pol_security_default",
isDefault: true, status: "COMPLETED",
stats: { enforced: 4821, dataProcessed: "2.3 GB", activeFor: "34d" }, currentStep: 2,
activity: [ stepCount: 2,
{ error: null,
doc: "Q2-vendor-agreement.pdf", outputs: [{ fileId: "f1", fileName: "Q2-vendor-agreement.pdf" }],
action: "Redacted 6 PII matches • JavaScript stripped", createdAt: NOW - 12 * M,
time: "12m ago",
status: "enforced",
},
{
doc: "patient-intake-0481.pdf",
action: "Low-confidence match — routed for review",
time: "1h ago",
status: "flagged",
},
{
doc: "invoice-7782.pdf",
action: "Enforcing…",
time: "just now",
status: "processing",
},
],
}, },
}; {
} runId: "run_002",
policyId: "pol_security_default",
/** Empty stats/activity for a freshly-configured policy with no runs yet. */ status: "FAILED",
export function emptyRuntime(reviewerEmail = "you@acme.com"): PolicyRuntime { currentStep: 1,
return { stepCount: 2,
scopeTypes: [], error: "Low-confidence match — routed for review",
reviewerEmail, outputs: [{ fileId: "f2", fileName: "patient-intake-0481.pdf" }],
fieldValues: {}, createdAt: NOW - 1 * H,
stats: { enforced: 0, dataProcessed: "0 B", activeFor: "—" }, },
activity: [], {
}; 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),
})),
];
} }
+159 -137
View File
@@ -31,21 +31,16 @@
/* Category grid */ /* Category grid */
.portal-policies__grid { .portal-policies__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.875rem;
}
@media (max-width: 56rem) {
.portal-policies__grid {
grid-template-columns: 1fr;
}
}
/* Category card */
.portal-policies__card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.375rem;
}
/* Category card — horizontal table row */
.portal-policies__card {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem; gap: 0.75rem;
} }
@@ -53,10 +48,47 @@
opacity: 0.7; opacity: 0.7;
} }
.portal-policies__card-head { .portal-policies__card-identity {
display: flex; display: flex;
align-items: flex-start; flex-direction: column;
gap: 0.625rem; gap: 0.125rem;
flex: 1;
min-width: 0;
}
.portal-policies__card-enforces {
font-size: 0.75rem;
color: var(--color-text-4);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.portal-policies__card-meta {
display: flex;
align-items: center;
gap: 2rem;
flex-shrink: 0;
}
.portal-policies__card-statpair {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.0625rem;
}
.portal-policies__card-statval {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-1);
line-height: 1.2;
}
.portal-policies__card-statlbl {
font-size: 0.6875rem;
color: var(--color-text-4);
line-height: 1.2;
} }
.portal-policies__cat-icon { .portal-policies__cat-icon {
@@ -66,41 +98,8 @@
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
flex-shrink: 0; flex-shrink: 0;
border-radius: var(--radius-md); font-size: 1.125rem;
font-size: 1rem; color: var(--color-text-2);
}
.portal-policies__cat-icon--neutral {
background: var(--color-bg-subtle);
color: var(--color-text-3);
}
.portal-policies__cat-icon--blue {
background: var(--color-blue-light);
color: var(--color-blue);
}
.portal-policies__cat-icon--purple {
background: var(--color-purple-light);
color: var(--color-purple);
}
.portal-policies__cat-icon--green {
background: var(--color-green-light);
color: var(--color-green-dark);
}
.portal-policies__cat-icon--amber {
background: var(--color-amber-light);
color: var(--color-amber-dark);
}
.portal-policies__cat-icon--red {
background: var(--color-red-light);
color: var(--color-red);
}
.portal-policies__card-titles {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
flex: 1;
} }
.portal-policies__card-title { .portal-policies__card-title {
@@ -110,50 +109,6 @@
color: var(--color-text-1); color: var(--color-text-1);
} }
.portal-policies__card-blurb {
font-size: 0.75rem;
color: var(--color-text-4);
line-height: 1.4;
}
.portal-policies__card-summary {
margin: 0;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--color-text-2);
}
.portal-policies__card-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-top: auto;
flex-wrap: wrap;
}
.portal-policies__card-rules {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.portal-policies__card-cta {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-blue);
}
/* Configured-card stat footer */
.portal-policies__card-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
margin-top: auto;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border-light);
}
/* Wizard + detail shared chrome */ /* Wizard + detail shared chrome */
.portal-policies__wizard-title { .portal-policies__wizard-title {
display: inline-flex; display: inline-flex;
@@ -196,6 +151,13 @@
font-weight: 600; font-weight: 600;
} }
.portal-policies__wizard-subheading {
margin: 0.875rem 0 0;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-3);
}
.portal-policies__fields { .portal-policies__fields {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -225,17 +187,6 @@
color: var(--color-text-1); color: var(--color-text-1);
} }
.portal-policies__tool-endpoint {
flex: 1;
min-width: 0;
font-family: var(--font-mono, monospace);
font-size: 0.6875rem;
color: var(--color-text-4);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Sources picker */ /* Sources picker */
.portal-policies__sources { .portal-policies__sources {
display: grid; display: grid;
@@ -251,12 +202,12 @@
.portal-policies__source { .portal-policies__source {
display: flex; display: flex;
align-items: flex-start; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.625rem; padding: 0.625rem;
text-align: left; text-align: left;
background: var(--color-surface); background: var(--color-surface);
border: 1px solid var(--color-border); border: 1.5px solid var(--color-border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
cursor: pointer; cursor: pointer;
transition: transition:
@@ -264,6 +215,22 @@
background var(--motion-fast); background var(--motion-fast);
} }
.portal-policies__source::after {
content: "";
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-left: auto;
width: 1rem;
height: 1rem;
border-radius: var(--radius-sm);
border: 1.5px solid var(--color-border-strong);
transition:
border-color var(--motion-fast),
background var(--motion-fast);
}
.portal-policies__source:hover { .portal-policies__source:hover {
background: var(--color-bg-hover); background: var(--color-bg-hover);
} }
@@ -273,12 +240,26 @@
background: var(--color-blue-light); background: var(--color-blue-light);
} }
.portal-policies__source--on::after {
content: "✓";
font-size: 0.6875rem;
font-weight: 700;
color: #fff;
border-color: var(--color-blue);
background: var(--color-blue);
}
.portal-policies__source-icon { .portal-policies__source-icon {
font-size: 1rem; font-size: 1rem;
line-height: 1.2; line-height: 1.2;
flex-shrink: 0;
color: var(--color-text-3); color: var(--color-text-3);
} }
.portal-policies__source--on .portal-policies__source-icon {
color: var(--color-blue);
}
.portal-policies__source-text { .portal-policies__source-text {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -338,8 +319,13 @@
.portal-policies__detail-status { .portal-policies__detail-status {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.625rem; gap: 0.5rem;
margin-bottom: 0.25rem; margin-bottom: 0.625rem;
}
.portal-policies__detail-sep {
color: var(--color-text-5);
font-size: 0.75rem;
} }
.portal-policies__detail-meta { .portal-policies__detail-meta {
@@ -347,28 +333,31 @@
color: var(--color-text-4); color: var(--color-text-4);
} }
.portal-policies__enforce-flow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem;
}
.portal-policies__enforce-item {
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
.portal-policies__enforce-arrow { .portal-policies__enforce-arrow {
color: var(--color-text-5); color: var(--color-text-5);
font-size: 0.75rem; font-size: 0.75rem;
} }
.portal-policies__enforce-note { .portal-policies__detail-inline {
margin: 0.75rem 0 0; display: flex;
font-size: 0.75rem; align-items: baseline;
gap: 0.625rem;
margin-bottom: 0.5rem;
}
.portal-policies__detail-inline-label {
font-size: 0.6875rem;
font-weight: 600;
color: var(--color-text-4); color: var(--color-text-4);
text-transform: uppercase;
letter-spacing: 0.04em;
flex-shrink: 0;
width: 4.5rem;
}
.portal-policies__detail-inline-value {
font-size: 0.8125rem;
color: var(--color-text-2);
line-height: 1.5; line-height: 1.5;
} }
@@ -384,22 +373,32 @@
border-top: 1px solid var(--color-border-light); border-top: 1px solid var(--color-border-light);
} }
.portal-policies__activity-dot { .portal-policies__activity-icon {
width: 0.5rem;
height: 0.5rem;
flex-shrink: 0; flex-shrink: 0;
margin-top: 0.375rem; margin-top: 0.125rem;
border-radius: 50%; display: flex;
align-items: flex-start;
padding-top: 0.125rem;
} }
.portal-policies__activity-dot--success { .portal-policies__activity-icon--success {
background: var(--color-green); color: var(--color-green);
} }
.portal-policies__activity-dot--warning { .portal-policies__activity-icon--warning {
background: var(--color-amber); color: var(--color-amber);
} }
.portal-policies__activity-dot--info { .portal-policies__activity-icon--info {
background: var(--color-blue); color: var(--color-blue);
}
@keyframes portal-policies-spin {
to {
transform: rotate(360deg);
}
}
.portal-policies__activity-spin {
animation: portal-policies-spin 1s linear infinite;
} }
.portal-policies__activity-text { .portal-policies__activity-text {
@@ -427,6 +426,29 @@
white-space: nowrap; white-space: nowrap;
} }
.portal-policies__activity-retry {
flex-shrink: 0;
font-size: 0.75rem;
}
/* Error expand/collapse for long activity messages */
.portal-policies__activity-error {
display: block;
}
.portal-policies__activity-error-text--clamped {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.portal-policies__activity-error-toggle {
display: block;
margin-top: 0.125rem;
font-size: 0.6875rem;
}
/* Detail stats footer */ /* Detail stats footer */
.portal-policies__detail-stats { .portal-policies__detail-stats {
display: grid; display: grid;
+55 -65
View File
@@ -1,15 +1,18 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Skeleton } from "@shared/components"; import { Banner, Button, Skeleton } from "@shared/components";
import { errorMessage } from "@portal/api/http";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import { import {
buildWireFromSetup,
buildWireFromState,
deletePolicy, deletePolicy,
fetchPolicies, fetchPolicies,
runPolicy,
savePolicy, savePolicy,
POLICY_CATEGORIES,
POLICY_CONFIG,
type CatalogueEntry, type CatalogueEntry,
type PoliciesResponse, type PoliciesResponse,
type Policy,
type PolicySetupResult, type PolicySetupResult,
} from "@portal/api/policies"; } from "@portal/api/policies";
import { CatalogueSummary } from "@portal/components/policies/CatalogueSummary"; import { CatalogueSummary } from "@portal/components/policies/CatalogueSummary";
@@ -18,51 +21,36 @@ import { PolicyDetailPanel } from "@portal/components/policies/PolicyDetailPanel
import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard"; import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard";
import "@portal/views/Policies.css"; import "@portal/views/Policies.css";
/**
* Translate the setup flow's collected result into the backend `Policy` wire
* record (Policy.java): the tool chain becomes the ordered pipeline `steps`,
* the run event becomes the `trigger`, and the output settings become `output`.
* Reuses the existing record's id on edit so the POST updates in place.
*/
function toWirePolicy(
entry: CatalogueEntry,
result: PolicySetupResult,
): Policy {
return {
id: entry.policy?.state.backendId ?? "",
name: `${entry.category.label} Policy`,
enabled: entry.policy ? entry.policy.state.status !== "paused" : true,
trigger: { event: result.runOn },
sources: result.sources.map((source) => ({ source })),
steps: result.steps,
output: {
mode: result.outputMode,
name: result.outputName,
namePosition: result.outputNamePosition,
},
categoryId: entry.category.id,
};
}
export function Policies() { export function Policies() {
const { t } = useTranslation(); const { t } = useTranslation();
// The catalogue is refetched after every mutation by bumping this counter,
// so the cards/detail reflect the in-memory store the handlers maintain.
const [version, setVersion] = useState(0); const [version, setVersion] = useState(0);
const state = useAsync<PoliciesResponse>(() => fetchPolicies(), [version]); const state = useAsync<PoliciesResponse>(() => fetchPolicies(), [version]);
const { data, loading } = state; const { data, loading, error: fetchError } = state;
const { isLoading } = useSectionFlags(state); const { isLoading } = useSectionFlags(state);
// The category whose detail panel is open (configured), and the one whose
// setup wizard is open. Both reference a catalogue entry.
const [detail, setDetail] = useState<CatalogueEntry | null>(null); const [detail, setDetail] = useState<CatalogueEntry | null>(null);
const [wizard, setWizard] = useState<CatalogueEntry | null>(null); const [wizard, setWizard] = useState<CatalogueEntry | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [pageError, setPageError] = useState<string | null>(null);
const catalogue = data?.catalogue ?? []; const catalogue = data?.catalogue ?? [];
const refetch = useCallback(() => setVersion((v) => v + 1), []); const refetch = useCallback(() => setVersion((v) => v + 1), []);
// Open the detail panel for configured categories, the wizard otherwise. const displayCatalogue: CatalogueEntry[] =
catalogue.length > 0
? catalogue
: POLICY_CATEGORIES.map((cat) => ({
category: cat,
config: POLICY_CONFIG[cat.id] ?? {
summary: "",
rules: [],
scopeLabel: "",
fields: [],
defaultOperations: [],
},
policy: null,
}));
function openEntry(entry: CatalogueEntry) { function openEntry(entry: CatalogueEntry) {
if (entry.policy) setDetail(entry); if (entry.policy) setDetail(entry);
else setWizard(entry); else setWizard(entry);
@@ -72,50 +60,39 @@ export function Policies() {
entry: CatalogueEntry, entry: CatalogueEntry,
result: PolicySetupResult, result: PolicySetupResult,
) { ) {
await savePolicy(toWirePolicy(entry, result)); setPageError(null);
setWizard(null); try {
setDetail(null); await savePolicy(buildWireFromSetup(entry, result));
refetch(); setWizard(null);
setDetail(null);
refetch();
} catch (e) {
setPageError(errorMessage(e));
}
} }
async function runLifecycle(action: () => Promise<unknown>) { async function runLifecycle(action: () => Promise<unknown>) {
if (busy) return; if (busy) return;
setPageError(null);
setBusy(true); setBusy(true);
try { try {
await action(); await action();
setDetail(null); setDetail(null);
refetch(); refetch();
} catch (e) {
setPageError(errorMessage(e));
} finally { } finally {
setBusy(false); setBusy(false);
} }
} }
function handleRun() {
const id = detail?.policy?.state.backendId;
if (id) void runLifecycle(() => runPolicy(id));
}
function handleTogglePause() { function handleTogglePause() {
const entry = detail; const entry = detail;
const policy = entry?.policy; const policy = entry?.policy;
if (!entry || !policy?.state.backendId) return; if (!entry || !policy?.state.backendId) return;
// Pause/resume is a re-save with the enabled flag flipped (the backend has const enabled = policy.state.status === "paused";
// no dedicated endpoint — every mutation routes through POST /policies).
void runLifecycle(() => void runLifecycle(() =>
savePolicy({ savePolicy(buildWireFromState(entry, policy, enabled)),
id: policy.state.backendId!,
name: `${entry.category.label} Policy`,
enabled: policy.state.status === "paused",
trigger: { event: policy.state.runOn ?? "upload" },
sources: policy.state.sources.map((source) => ({ source })),
steps: policy.steps,
output: {
mode: policy.state.outputMode ?? "new_version",
name: policy.state.outputName ?? "",
namePosition: "suffix",
},
categoryId: entry.category.id,
}),
); );
} }
@@ -124,7 +101,6 @@ export function Policies() {
if (id) void runLifecycle(() => deletePolicy(id)); if (id) void runLifecycle(() => deletePolicy(id));
} }
// Reopen the wizard for the policy currently shown in the detail panel.
function handleEdit() { function handleEdit() {
if (detail) { if (detail) {
setWizard(detail); setWizard(detail);
@@ -139,19 +115,34 @@ export function Policies() {
<p className="portal-policies__sub">{t("policies.subtitle")}</p> <p className="portal-policies__sub">{t("policies.subtitle")}</p>
</header> </header>
{pageError && <Banner tone="danger" description={pageError} />}
<CatalogueSummary data={data} loading={loading} /> <CatalogueSummary data={data} loading={loading} />
{isLoading && ( {isLoading && (
<div className="portal-policies__grid" aria-hidden> <div className="portal-policies__grid" aria-hidden>
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} height="11rem" /> <Skeleton key={i} height="3.5rem" />
))} ))}
</div> </div>
)} )}
{!isLoading && catalogue.length > 0 && ( {!isLoading && fetchError && (
<Banner
tone="warning"
title={t("policies.offline.title")}
description={t("policies.offline.description")}
action={
<Button variant="outline" size="sm" onClick={refetch}>
{t("policies.offline.retry")}
</Button>
}
/>
)}
{!isLoading && !fetchError && (
<div className="portal-policies__grid"> <div className="portal-policies__grid">
{catalogue.map((entry) => ( {displayCatalogue.map((entry) => (
<PolicyCategoryCard <PolicyCategoryCard
key={entry.category.id} key={entry.category.id}
entry={entry} entry={entry}
@@ -166,7 +157,6 @@ export function Policies() {
busy={busy} busy={busy}
onClose={() => setDetail(null)} onClose={() => setDetail(null)}
onEdit={handleEdit} onEdit={handleEdit}
onRun={handleRun}
onTogglePause={handleTogglePause} onTogglePause={handleTogglePause}
onDelete={handleDelete} onDelete={handleDelete}
/> />
+8 -1
View File
@@ -23,12 +23,19 @@ export default defineConfig({
"@shared": sharedDir, "@shared": sharedDir,
}, },
}, },
server: {
fs: {
// Allow Vite to serve files from the shared/ sibling directory when
// running tests with --root portal (which would otherwise block them).
allow: [".."],
},
},
test: { test: {
globals: true, globals: true,
environment: "jsdom", environment: "jsdom",
setupFiles: ["./src/setupTests.ts"], setupFiles: ["./src/setupTests.ts"],
css: false, css: false,
include: ["src/**/*.test.{ts,tsx}"], include: ["src/**/*.test.{ts,tsx}", "../shared/**/*.test.ts"],
testTimeout: 10000, testTimeout: 10000,
hookTimeout: 10000, hookTimeout: 10000,
}, },
+121
View File
@@ -0,0 +1,121 @@
import { describe, it, expect } from "vitest";
import { toWirePolicy, fromWirePolicy } from "@shared/policies/codec";
import type { PolicyDecodedState } from "@shared/policies/types";
const FULL_STATE: PolicyDecodedState = {
id: "pol_123",
name: "Security Policy",
enabled: true,
categoryId: "security",
sources: ["editor", "gdrive"],
scopeTypes: ["Contracts", "Invoices"],
reviewerEmail: "admin@example.com",
fieldValues: { auditTrail: true, frameworks: ["HIPAA"] },
runOn: "upload",
outputMode: "new_version",
outputName: "redacted",
outputNamePosition: "prefix",
maxRetries: 3,
retryDelayMinutes: 5,
steps: [
{
operation: "/api/v1/security/auto-redact",
parameters: { mode: "automatic" },
},
],
};
describe("toWirePolicy", () => {
it("sets trigger to null", () => {
expect(toWirePolicy(FULL_STATE).trigger).toBeNull();
});
it("sets output.type to inline", () => {
expect(toWirePolicy(FULL_STATE).output.type).toBe("inline");
});
it("packs metadata into output.options", () => {
const wire = toWirePolicy(FULL_STATE);
const opts = wire.output.options;
expect(opts.categoryId).toBe("security");
expect(opts.sources).toEqual(["editor", "gdrive"]);
expect(opts.runOn).toBe("upload");
expect(opts.mode).toBe("new_version");
expect(opts.position).toBe("prefix");
});
it("preserves steps at the top level", () => {
const wire = toWirePolicy(FULL_STATE);
expect(wire.steps).toEqual(FULL_STATE.steps);
});
});
describe("fromWirePolicy → round-trip", () => {
it("recovers all fields after encode→decode", () => {
const wire = toWirePolicy(FULL_STATE);
const decoded = fromWirePolicy(wire);
expect(decoded.id).toBe(FULL_STATE.id);
expect(decoded.categoryId).toBe(FULL_STATE.categoryId);
expect(decoded.sources).toEqual(FULL_STATE.sources);
expect(decoded.scopeTypes).toEqual(FULL_STATE.scopeTypes);
expect(decoded.reviewerEmail).toBe(FULL_STATE.reviewerEmail);
expect(decoded.fieldValues).toEqual(FULL_STATE.fieldValues);
expect(decoded.runOn).toBe(FULL_STATE.runOn);
expect(decoded.outputMode).toBe(FULL_STATE.outputMode);
expect(decoded.outputName).toBe(FULL_STATE.outputName);
expect(decoded.outputNamePosition).toBe(FULL_STATE.outputNamePosition);
expect(decoded.maxRetries).toBe(FULL_STATE.maxRetries);
expect(decoded.retryDelayMinutes).toBe(FULL_STATE.retryDelayMinutes);
expect(decoded.steps).toEqual(FULL_STATE.steps);
});
it("defaults runOn to upload when missing", () => {
const wire = toWirePolicy(FULL_STATE);
delete (wire.output.options as Record<string, unknown>).runOn;
expect(fromWirePolicy(wire).runOn).toBe("upload");
});
it("defaults outputMode to new_version when missing", () => {
const wire = toWirePolicy(FULL_STATE);
delete (wire.output.options as Record<string, unknown>).mode;
expect(fromWirePolicy(wire).outputMode).toBe("new_version");
});
it("preserves export runOn", () => {
const wire = toWirePolicy({ ...FULL_STATE, runOn: "export" });
expect(fromWirePolicy(wire).runOn).toBe("export");
});
it("preserves new_file outputMode", () => {
const wire = toWirePolicy({ ...FULL_STATE, outputMode: "new_file" });
expect(fromWirePolicy(wire).outputMode).toBe("new_file");
});
it("preserves all three outputNamePosition values", () => {
for (const pos of ["prefix", "suffix", "auto-number"] as const) {
const wire = toWirePolicy({ ...FULL_STATE, outputNamePosition: pos });
expect(fromWirePolicy(wire).outputNamePosition).toBe(pos);
}
});
it("handles empty options gracefully", () => {
const decoded = fromWirePolicy({
id: "x",
name: "X",
enabled: false,
trigger: null,
steps: [],
output: { type: "inline", options: {} },
});
expect(decoded.categoryId).toBe("");
expect(decoded.sources).toEqual([]);
expect(decoded.runOn).toBe("upload");
expect(decoded.outputMode).toBe("new_version");
});
it("defaults fieldValues to empty object when missing", () => {
const wire = toWirePolicy(FULL_STATE);
delete (wire.output.options as Record<string, unknown>).fieldValues;
expect(fromWirePolicy(wire).fieldValues).toEqual({});
});
});
+77
View File
@@ -0,0 +1,77 @@
/**
* Bidirectional codec between the portal's frontend `PolicyDecodedState` and
* the backend `WirePolicy`. All policy-level metadata rides in
* `output.options`; `trigger` is always null (the editor fires runs on
* upload/export via `/run`). Mirrors the editor's `buildBackendPolicy` /
* `fromBackendPolicy` from `policyPipeline.ts`, minus the editor-only
* `automation` blob and toolRegistry coupling.
*/
import type {
PolicyDecodedState,
WireOutputOptions,
WirePolicy,
} from "@shared/policies/types";
const DEFAULTS = {
maxRetries: 3,
retryDelayMinutes: 5,
} as const;
export function toWirePolicy(state: PolicyDecodedState): WirePolicy {
const options: WireOutputOptions = {
runOn: state.runOn,
mode: state.outputMode,
name: state.outputName,
position: state.outputNamePosition,
maxRetries: state.maxRetries,
retryDelayMinutes: state.retryDelayMinutes,
categoryId: state.categoryId,
sources: state.sources,
scopeTypes: state.scopeTypes,
reviewerEmail: state.reviewerEmail,
fieldValues: state.fieldValues,
};
return {
id: state.id,
name: state.name,
owner: "",
enabled: state.enabled,
trigger: null,
steps: state.steps,
output: { type: "inline", options },
};
}
export function fromWirePolicy(policy: WirePolicy): PolicyDecodedState {
const raw = policy.output?.options ?? {};
const str = (v: unknown, fallback = "") =>
typeof v === "string" ? v : fallback;
const num = (v: unknown, fallback: number) =>
typeof v === "number" ? v : fallback;
const position =
raw.position === "suffix"
? "suffix"
: raw.position === "auto-number"
? "auto-number"
: "prefix";
return {
id: policy.id,
name: policy.name,
enabled: policy.enabled,
categoryId: str(raw.categoryId),
sources: Array.isArray(raw.sources) ? (raw.sources as string[]) : [],
scopeTypes: Array.isArray(raw.scopeTypes)
? (raw.scopeTypes as string[])
: [],
reviewerEmail: str(raw.reviewerEmail),
fieldValues: raw.fieldValues ?? {},
runOn: raw.runOn === "export" ? "export" : "upload",
outputMode: raw.mode === "new_file" ? "new_file" : "new_version",
outputName: str(raw.name),
outputNamePosition: position,
maxRetries: num(raw.maxRetries, DEFAULTS.maxRetries),
retryDelayMinutes: num(raw.retryDelayMinutes, DEFAULTS.retryDelayMinutes),
steps: Array.isArray(policy.steps) ? policy.steps : [],
};
}
+150
View File
@@ -0,0 +1,150 @@
import { describe, it, expect } from "vitest";
import { runsToStats, runsToActivity } from "@shared/policies/runs";
import type { PolicyRunView } from "@shared/policies/types";
const MIN = 60000;
const HOUR = 3600000;
const DAY = 86400000;
// Use the actual current time so relative-time formatting in runs.ts is correct.
const NOW = Date.now();
const completed = (
id: string,
ts: number,
file = "doc.pdf",
): PolicyRunView => ({
runId: id,
policyId: "pol_1",
status: "COMPLETED",
currentStep: 2,
stepCount: 2,
error: null,
outputs: [{ fileId: "f", fileName: file }],
createdAt: ts,
});
const failed = (
id: string,
ts: number,
err = "Redaction failed",
): PolicyRunView => ({
runId: id,
policyId: "pol_1",
status: "FAILED",
currentStep: 1,
stepCount: 2,
error: err,
outputs: [],
createdAt: ts,
});
const running = (id: string, ts: number, step = 1): PolicyRunView => ({
runId: id,
policyId: "pol_1",
status: "RUNNING",
currentStep: step,
stepCount: 2,
error: null,
outputs: [],
createdAt: ts,
});
describe("runsToStats", () => {
it("counts only COMPLETED runs as enforced", () => {
const runs: PolicyRunView[] = [
completed("a", NOW - 10 * MIN),
completed("b", NOW - 20 * MIN),
failed("c", NOW - 30 * MIN),
running("d", NOW - 5 * MIN),
];
expect(runsToStats(runs).enforced).toBe(2);
});
it("returns — for dataProcessed (not available from wire)", () => {
expect(runsToStats([completed("a", NOW - MIN)]).dataProcessed).toBe("—");
});
it("returns — for activeFor when there are no runs", () => {
expect(runsToStats([]).activeFor).toBe("—");
});
it("computes activeFor from the oldest run", () => {
const runs = [completed("a", NOW - 2 * DAY), completed("b", NOW - 5 * DAY)];
expect(runsToStats(runs).activeFor).toBe("5d");
});
});
describe("runsToActivity", () => {
it("maps COMPLETED to enforced status", () => {
const [row] = runsToActivity([
completed("a", NOW - 10 * MIN, "invoice.pdf"),
]);
expect(row.status).toBe("enforced");
expect(row.doc).toBe("invoice.pdf");
});
it("maps FAILED to flagged status with the error message", () => {
const [row] = runsToActivity([failed("b", NOW - HOUR, "Low confidence")]);
expect(row.status).toBe("flagged");
expect(row.action).toBe("Low confidence");
});
it("maps RUNNING to processing status", () => {
const [row] = runsToActivity([running("c", NOW - MIN)]);
expect(row.status).toBe("processing");
});
it("maps CANCELLED to flagged status", () => {
const run: PolicyRunView = {
...failed("x", NOW - MIN, "Cancelled by user"),
status: "CANCELLED",
};
expect(runsToActivity([run])[0].status).toBe("flagged");
});
it("maps PENDING to processing status", () => {
const run: PolicyRunView = {
...running("x", NOW - MIN),
status: "PENDING",
};
expect(runsToActivity([run])[0].status).toBe("processing");
});
it("maps WAITING_FOR_INPUT to processing status", () => {
const run: PolicyRunView = {
...running("x", NOW - MIN),
status: "WAITING_FOR_INPUT",
};
expect(runsToActivity([run])[0].status).toBe("processing");
});
it("falls back to 'Enforcement failed' when FAILED run has no error message", () => {
const run: PolicyRunView = { ...failed("x", NOW - MIN), error: null };
expect(runsToActivity([run])[0].action).toBe("Enforcement failed");
});
it("shows step progress when currentStep and stepCount are set", () => {
const [row] = runsToActivity([running("c", NOW - MIN, 1)]);
expect(row.action).toContain("step 1/2");
});
it("falls back to Policy run when outputs is empty", () => {
const noOutput: PolicyRunView = { ...running("x", NOW - MIN), outputs: [] };
expect(runsToActivity([noOutput])[0].doc).toBe("Policy run");
});
it("formats recent timestamps as Nm ago", () => {
const [row] = runsToActivity([completed("a", NOW - 5 * MIN)]);
expect(row.time).toBe("5m ago");
});
it("formats hour-range timestamps as Nh ago", () => {
const [row] = runsToActivity([completed("a", NOW - 2 * HOUR)]);
expect(row.time).toBe("2h ago");
});
it("formats day-range timestamps as Nd ago", () => {
const [row] = runsToActivity([completed("a", NOW - 3 * DAY)]);
expect(row.time).toBe("3d ago");
});
});
+65
View File
@@ -0,0 +1,65 @@
/**
* Derives display data from raw backend `PolicyRunView` records. The backend
* `GET /api/v1/policies/runs` endpoint returns these; both the portal and
* (eventually) the editor read this same derivation rather than duplicating it.
*/
import type {
PolicyActivityItem,
PolicyRunView,
PolicyStats,
} from "@shared/policies/types";
function relativeTime(epochMs: number): string {
if (!epochMs) return "Just now";
const mins = Math.floor((Date.now() - epochMs) / 60000);
if (mins < 1) return "Just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
function durationSince(epochMs: number): string {
if (!epochMs) return "—";
const ms = Date.now() - epochMs;
const days = Math.floor(ms / 86400000);
if (days >= 1) return `${days}d`;
const hrs = Math.floor(ms / 3600000);
return hrs >= 1 ? `${hrs}h` : "Today";
}
function activityStatus(run: PolicyRunView): PolicyActivityItem["status"] {
if (run.status === "COMPLETED") return "enforced";
if (run.status === "FAILED" || run.status === "CANCELLED") return "flagged";
return "processing";
}
function activityAction(run: PolicyRunView): string {
const s = activityStatus(run);
if (s === "enforced") return "Enforced";
if (s === "flagged") return run.error ?? "Enforcement failed";
const { currentStep, stepCount } = run;
return currentStep && stepCount
? `Enforcing… · step ${currentStep}/${stepCount}`
: "Enforcing…";
}
export function runsToActivity(runs: PolicyRunView[]): PolicyActivityItem[] {
return runs.map((run) => ({
doc: run.outputs[0]?.fileName ?? "Policy run",
action: activityAction(run),
time: relativeTime(run.createdAt),
status: activityStatus(run),
}));
}
export function runsToStats(runs: PolicyRunView[]): PolicyStats {
const completed = runs.filter((r) => r.status === "COMPLETED");
const oldest = runs.reduce((min, r) => Math.min(min, r.createdAt), Infinity);
return {
enforced: completed.length,
dataProcessed: "—",
activeFor: isFinite(oldest) ? durationSince(oldest) : "—",
};
}
+112
View File
@@ -0,0 +1,112 @@
/**
* Wire types for the Stirling policy API (`/api/v1/policies`), the decoded
* frontend shape, and the display types used to render stats and activity.
*
* The backend stores all portal-level metadata (categoryId, sources, scope,
* reviewer, fieldValues, runOn, output settings) inside `output.options` — the
* same "options bag" the editor uses. `trigger` is always null for
* portal/editor-authored policies; the editor fires runs on upload/export via
* `/run`, so there is no server-side trigger.
*/
// ── Wire types (match Policy.java / PipelineStep.java / PolicyRunView.java) ──
export interface WirePipelineStep {
operation: string;
parameters: Record<string, unknown>;
fileParameters?: Record<string, string>;
}
export interface WireOutputOptions {
runOn: "upload" | "export";
mode: "new_file" | "new_version";
name: string;
position: "prefix" | "suffix" | "auto-number";
maxRetries?: number;
retryDelayMinutes?: number;
categoryId: string;
sources: string[];
scopeTypes: string[];
reviewerEmail: string;
fieldValues: Record<string, boolean | string | string[]>;
}
export interface WireOutputSpec {
type: "inline";
options: Partial<WireOutputOptions>;
}
export interface WirePolicy {
id: string;
name: string;
owner?: string;
enabled: boolean;
trigger: null;
steps: WirePipelineStep[];
output: WireOutputSpec;
teamId?: string;
}
// ── Run view (mirrors PolicyRunView.java) ─────────────────────────────────────
export type PolicyRunStatus =
| "PENDING"
| "RUNNING"
| "WAITING_FOR_INPUT"
| "COMPLETED"
| "FAILED"
| "CANCELLED";
export interface PolicyRunView {
runId: string;
policyId: string | null;
status: PolicyRunStatus;
currentStep: number;
stepCount: number;
error: string | null;
errorCode?: string | null;
errorSubscribed?: boolean | null;
outputs: { fileId: string; fileName: string }[];
/** Creation timestamp in epoch milliseconds. */
createdAt: number;
}
// ── Frontend decoded shape ────────────────────────────────────────────────────
/** Policy settings unpacked from the wire record's `output.options` bag. */
export interface PolicyDecodedState {
id: string;
name: string;
enabled: boolean;
categoryId: string;
sources: string[];
scopeTypes: string[];
reviewerEmail: string;
fieldValues: Record<string, boolean | string | string[]>;
runOn: "upload" | "export";
outputMode: "new_file" | "new_version";
outputName: string;
outputNamePosition: "prefix" | "suffix" | "auto-number";
maxRetries: number;
retryDelayMinutes: number;
steps: WirePipelineStep[];
}
// ── Display types (returned by runs.ts derivations) ───────────────────────────
export interface PolicyStats {
/** Completed enforcement runs. */
enforced: number;
/** Human-formatted total data processed (e.g. "2.3 GB"), or "—" when unknown. */
dataProcessed: string;
/** Human-formatted duration since first run (e.g. "34d"), or "—". */
activeFor: string;
}
export interface PolicyActivityItem {
doc: string;
action: string;
/** Relative timestamp, e.g. "2h ago". */
time: string;
status: "enforced" | "flagged" | "processing";
}
+11 -11
View File
@@ -1,24 +1,24 @@
# Prints one free TCP port per preferred port given as an argument. # Prints one free TCP port per preferred port given as an argument.
# #
# For each element of -Preferred, emits that port if it's free; otherwise # For each element of -Preferred, emits that port if it's free; otherwise
# emits a random free port in 20000-49999. Probes by attempting to bind a # emits a random free port in 20000-49999. Uses Get-NetTCPConnection to read
# TcpListener on loopback. Tracks picks within this run so outputs are # the OS socket table directly — more reliable than TcpListener binding on
# guaranteed distinct from each other. # Windows where SO_REUSEADDR can cause false "free" results. Tracks picks
# within this run so outputs are guaranteed distinct from each other.
param([Parameter(ValueFromRemainingArguments = $true)][int[]]$Preferred) param([Parameter(ValueFromRemainingArguments = $true)][int[]]$Preferred)
$script:picked = @() $script:picked = @()
# Build a set of ports currently in LISTEN or ESTABLISHED state once upfront.
$usedPorts = [System.Collections.Generic.HashSet[int]]::new()
Get-NetTCPConnection -ErrorAction SilentlyContinue |
Where-Object { $_.State -in 'Listen', 'Established' } |
ForEach-Object { $null = $usedPorts.Add($_.LocalPort) }
function Test-PortFree { function Test-PortFree {
param([int]$Port) param([int]$Port)
if ($script:picked -contains $Port) { return $false } if ($script:picked -contains $Port) { return $false }
try { return -not $usedPorts.Contains($Port)
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $Port)
$listener.Start()
$listener.Stop()
return $true
} catch {
return $false
}
} }
function Get-RandomFreePort { function Get-RandomFreePort {