diff --git a/Taskfile.yml b/Taskfile.yml index f04967740f..34c7c8bc28 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -95,6 +95,7 @@ tasks: vars: PORT: '{{.PORTAL_PORT}}' BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' + MOCKS: 'false' OPEN: "true" dev:portal:all: @@ -117,6 +118,32 @@ tasks: BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' # Point the portal's "Editor" app switcher at the editor we spawn here. 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" - task: frontend:dev vars: diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index 19f88c9304..188b404d23 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -212,8 +212,12 @@ export function usePolicyAutoRun(): void { s.configured && s.status === "active" && s.backendId && - // Only auto-run on upload when the policy is set to run on upload - // (export-triggered policies enforce at export time instead). + // Only enforce in the editor when the policy includes "editor" as a source. + // 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", ); 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). const outputMode = policies[run.categoryId]?.outputMode ?? "new_version"; const outputName = policies[run.categoryId]?.outputName ?? ""; + const outputNamePosition = policies[run.categoryId]?.outputNamePosition; const parentStub = fileStubs.find((s) => (s.id as string) === run.fileId); void importOutputs(run, { addFiles, @@ -282,6 +287,7 @@ export function usePolicyAutoRun(): void { bumpRevision, outputMode, outputName, + outputNamePosition, parentStub, }).finally(() => importing.current.delete(run.runId)); } @@ -314,9 +320,11 @@ interface ImportContext { bumpRevision: () => void; /** "new_file" adds the output as a separate file; "new_version" versions the input. */ outputMode: "new_file" | "new_version"; - /** Rename rule. Empty → keep the input's filename; set → use the policy's - * renamed output (applied server-side per the name-position setting). */ + /** Rename rule. Empty → keep the input's filename. */ 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. */ 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 * 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( policies: PoliciesByCategory, ): Promise { @@ -406,7 +428,11 @@ async function importOutputs( // rule the backend's auto-suffixed name (e.g. "_watermarked_sanitized") would // otherwise rename every output. const targetName = ctx.outputName - ? undefined // use the run's per-output (renamed) name below + ? applyOutputName( + run.fileName, + ctx.outputName, + ctx.outputNamePosition ?? "suffix", + ) : run.fileName; const settled = await Promise.allSettled( pending.map(async (out) => { diff --git a/frontend/editor/src/proprietary/hooks/usePolicies.ts b/frontend/editor/src/proprietary/hooks/usePolicies.ts index 292cb9d0bc..356afa3164 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicies.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicies.ts @@ -134,6 +134,7 @@ export function usePolicies() { reviewerEmail: result.reviewerEmail, outputMode: result.folder.outputMode, outputName: result.folder.outputName, + outputNamePosition: result.folder.outputNamePosition, runOn: result.folder.runOn, }); }, @@ -170,6 +171,7 @@ export function usePolicies() { reviewerEmail: result.reviewerEmail, outputMode: result.folder.outputMode, outputName: result.folder.outputName, + outputNamePosition: result.folder.outputNamePosition, runOn: result.folder.runOn, }); }, @@ -234,6 +236,7 @@ export function usePolicies() { reviewerEmail: result.reviewerEmail, outputMode: result.folder.outputMode, outputName: result.folder.outputName, + outputNamePosition: result.folder.outputNamePosition, runOn: result.folder.runOn, }); }, diff --git a/frontend/editor/src/proprietary/services/policyBackend.ts b/frontend/editor/src/proprietary/services/policyBackend.ts index 5b3097e023..e1ee2400f4 100644 --- a/frontend/editor/src/proprietary/services/policyBackend.ts +++ b/frontend/editor/src/proprietary/services/policyBackend.ts @@ -53,6 +53,7 @@ export function decodedToState( fieldValues: decoded.fieldValues, outputMode: decoded.folder.outputMode, outputName: decoded.folder.outputName, + outputNamePosition: decoded.folder.outputNamePosition, runOn: decoded.folder.runOn, folderId: localFolderId, backendId: decoded.id, diff --git a/frontend/editor/src/proprietary/services/policyExport.ts b/frontend/editor/src/proprietary/services/policyExport.ts index 92b14712dd..6294c914ff 100644 --- a/frontend/editor/src/proprietary/services/policyExport.ts +++ b/frontend/editor/src/proprietary/services/policyExport.ts @@ -70,6 +70,7 @@ function activeExportPolicies(): ExportPolicy[] { s.configured && s.status === "active" && s.backendId && + (s.sources.length === 0 || s.sources.includes("editor")) && s.runOn === "export", ) .map(([id, s]) => ({ diff --git a/frontend/editor/src/proprietary/types/policies.ts b/frontend/editor/src/proprietary/types/policies.ts index c157f4759c..b0285a4d22 100644 --- a/frontend/editor/src/proprietary/types/policies.ts +++ b/frontend/editor/src/proprietary/types/policies.ts @@ -131,6 +131,9 @@ export interface PolicyState { * input's filename; when set, it's applied as a prefix/suffix per the policy's * name-position setting. */ 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". */ runOn?: "upload" | "export"; /** diff --git a/frontend/portal/public/locales/en-US/translation.toml b/frontend/portal/public/locales/en-US/translation.toml index b32f19c303..2de3f0369d 100644 --- a/frontend/portal/public/locales/en-US/translation.toml +++ b/frontend/portal/public/locales/en-US/translation.toml @@ -379,6 +379,9 @@ label = "Read mode" consume = "Consume: process each file once" snapshot = "Snapshot: re-read the folder every run" +[sources.types.editor] +label = "Editor" + [sources.types.unknown] label = "Source" @@ -696,6 +699,11 @@ description = "{{file}} is signed and ready to transfer. It activates one instan 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." +[policies.offline] +title = "Backend unavailable" +description = "Your policies are saved and will appear once the connection is restored." +retry = "Retry" + [policies.status] active = "Active" paused = "Paused" @@ -724,15 +732,17 @@ description = "Across active policies" [policies.card] comingSoon = "Coming soon" notSetUp = "Not set up" -setUp = "Set up →" [policies.detail] -title = "{{category}} policy" -meta = "Runs on {{event}} · output {{output}}" outputAsNewFile = "as a new file" outputAsNewVersion = "as a new version" 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" [policies.detail.actions] @@ -746,10 +756,6 @@ editSettings = "Edit settings" title = "No activity yet" description = "Documents will appear here once this policy runs." -[policies.detail.scoped] -title = "Scoped" -description = "Limited to: {{types}}" - [policies.wizard.title] edit = "Edit {{category}} policy" setUp = "Set up {{category}} policy" @@ -778,6 +784,9 @@ heading = "Settings" [policies.wizard.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] heading = "Document types" @@ -809,9 +818,10 @@ suffix = "Suffix" autoNumber = "Auto-number" placeholder = "Text to add (optional)" -[policies.wizard.output.reviewerEmail] -label = "Reviewer email" -helper = "Low-confidence enforcements are routed here for review." +[policies.wizard.output.retries] +heading = "Retries" +maxLabel = "Max retries" +delayLabel = "Retry delay (min)" [users.summary] members = "Members" diff --git a/frontend/portal/src/api/http.ts b/frontend/portal/src/api/http.ts index de8d89babc..a37df3ce81 100644 --- a/frontend/portal/src/api/http.ts +++ b/frontend/portal/src/api/http.ts @@ -39,7 +39,7 @@ * 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. */ -import { getStoredToken } from "@shared/auth"; +import { clearStoredToken, getStoredToken } from "@shared/auth"; import { getSupabaseClient } from "@shared/auth/supabase/supabaseClient"; import { ensureSaasSupabase } from "@portal/auth/saasSupabase"; @@ -156,6 +156,12 @@ async function localJson( body: options.body !== undefined ? JSON.stringify(options.body) : undefined, 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(res); } diff --git a/frontend/portal/src/api/policies.ts b/frontend/portal/src/api/policies.ts index fba5eda1eb..70eccd5cfb 100644 --- a/frontend/portal/src/api/policies.ts +++ b/frontend/portal/src/api/policies.ts @@ -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 - * one calls the REAL Stirling policy API base `/api/v1/policies` so it is - * genuinely plug-and-play: drop MSW and these exact calls hit the live backend - * (PolicyController). The list response is the portal's catalogue shape; the - * single-policy / create / delete / run calls match the backend records. + * The portal calls the real Stirling policy API (`/api/v1/policies`). MSW + * intercepts these calls in dev/Storybook; dropping MSW is enough to hit the + * live backend — no call-site changes needed. + * + * `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 { CatalogueEntry, DecoratedPolicy, - InputSpec, - OutputSpec, - PipelineStep, PoliciesResponse, PoliciesSummary, - Policy, - PolicyActivityItem, PolicyCategory, PolicyConfigDef, + PolicyDecodedState, PolicyField, PolicyFieldType, PolicyRowStatus, + PolicyRunView, PolicySetupResult, - PolicySource, PolicyState, PolicyStats, + PolicyActivityItem, PolicyStatus, - TriggerConfig, + WirePolicy, + WireOutputOptions, + WireOutputSpec, } from "@portal/mocks/policies"; export { ENDPOINT_LABELS, POLICY_CATEGORIES, POLICY_CONFIG, POLICY_DOC_TYPES, - POLICY_SOURCES, TOOL_ENDPOINTS, humanizeEndpoint, } 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 { - return apiClient.local.json("/api/v1/policies"); + const [wirePolicies, runs] = await Promise.all([ + apiClient.local.json("/api/v1/policies"), + apiClient.local + .json("/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. */ -export async function fetchPolicy(id: string): Promise { - return apiClient.local.json( +export async function fetchPolicy(id: string): Promise { + return apiClient.local.json( `/api/v1/policies/${encodeURIComponent(id)}`, ); } /** - * POST /api/v1/policies — create (blank id) or update (matched id). The backend - * assigns owner + team server-side and returns the stored policy with its id. + * POST /api/v1/policies — create (blank id) or update (matched id). The + * backend stamps owner + teamId server-side and returns the stored record. */ -export async function savePolicy(policy: Policy): Promise { - return apiClient.local.json("/api/v1/policies", { +export async function savePolicy(wire: WirePolicy): Promise { + return apiClient.local.json("/api/v1/policies", { 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 { await apiClient.local.json( `/api/v1/policies/${encodeURIComponent(id)}`, @@ -76,22 +177,80 @@ export async function deletePolicy(id: string): Promise { ); } -/** The async run acknowledgement: a run id to poll for status. */ -export interface PolicyRunResponse { - status: boolean; - /** The run id (poll GET /api/v1/policies/run/{id} for status). */ - fileId: string | null; - message: string | null; +// ── Wire-build helpers (so Policies.tsx doesn't need codec knowledge) ──────── + +const DEFAULT_RETRIES = 3; +const DEFAULT_RETRY_DELAY = 5; + +// 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 - * is multipart (the documents to process); the portal has no files to attach, - * so this triggers the policy on whatever the backend has queued and returns a - * run id. Runs regardless of the policy's enabled flag. + * POST /api/v1/policies/{id}/run — trigger a stored policy immediately. The + * real endpoint is multipart; the portal sends no files, relying on whatever + * the backend has queued for this policy. */ -export async function runPolicy(id: string): Promise { - return apiClient.local.json( +export async function runPolicy(id: string): Promise<{ runId: string }> { + return apiClient.local.json<{ runId: string }>( `/api/v1/policies/${encodeURIComponent(id)}/run`, { method: "POST" }, ); diff --git a/frontend/portal/src/components/policies/PolicyCategoryCard.tsx b/frontend/portal/src/components/policies/PolicyCategoryCard.tsx index fe999a1dfb..33466830d0 100644 --- a/frontend/portal/src/components/policies/PolicyCategoryCard.tsx +++ b/frontend/portal/src/components/policies/PolicyCategoryCard.tsx @@ -1,5 +1,5 @@ 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 { policyIcon } from "@portal/components/policies/policyIcons"; import "@portal/views/Policies.css"; @@ -9,17 +9,13 @@ interface PolicyCategoryCardProps { 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) { const { t } = useTranslation(); const { category, config, policy } = entry; const comingSoon = category.comingSoon === true; const openable = !comingSoon; const status = policy?.state.status; + const enforces = config.rules.join(" · "); return ( -
- - {policyIcon(category.icon)} - -
-

{category.label}

- {category.desc} -
- {comingSoon ? ( - - {t("policies.card.comingSoon")} - - ) : policy ? ( + + {policyIcon(category.icon)} + + +
+

{category.label}

+ {enforces && ( + {enforces} + )} +
+ + {comingSoon ? ( + + {t("policies.card.comingSoon")} + + ) : policy ? ( +
+ + + {policy.stats.enforced.toLocaleString()} + + + {t("policies.stats.docsEnforced")} + + + + + {policy.stats.dataProcessed} + + + {t("policies.stats.dataProcessed")} + + - ) : ( - - {t("policies.card.notSetUp")} - - )} -
- -

{config.summary}

- - {policy ? ( -
- - - -
+ ) : ( -
-
- {config.rules.slice(0, 3).map((rule) => ( - - {rule} - - ))} -
- {!comingSoon && ( - - {t("policies.card.setUp")} - - )} -
+ + {t("policies.card.notSetUp")} + )}
); diff --git a/frontend/portal/src/components/policies/PolicyDetailPanel.stories.tsx b/frontend/portal/src/components/policies/PolicyDetailPanel.stories.tsx index 215c319ba4..541ea558b0 100644 --- a/frontend/portal/src/components/policies/PolicyDetailPanel.stories.tsx +++ b/frontend/portal/src/components/policies/PolicyDetailPanel.stories.tsx @@ -12,6 +12,7 @@ const meta: Meta = { onRun: () => {}, onTogglePause: () => {}, onDelete: () => {}, + onRetry: () => {}, }, }; 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", + }, + ], + }, + }, +}; diff --git a/frontend/portal/src/components/policies/PolicyDetailPanel.tsx b/frontend/portal/src/components/policies/PolicyDetailPanel.tsx index cdf4399bd9..db4fd5bdf7 100644 --- a/frontend/portal/src/components/policies/PolicyDetailPanel.tsx +++ b/frontend/portal/src/components/policies/PolicyDetailPanel.tsx @@ -1,42 +1,102 @@ +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { - Banner, Button, Card, - Chip, EmptyState, Modal, StatTile, StatusBadge, } from "@shared/components"; -import { humanizeEndpoint, type DecoratedPolicy } from "@portal/api/policies"; -import { policyIcon } from "@portal/components/policies/policyIcons"; +import { + humanizeEndpoint, + type DecoratedPolicy, + type PolicyActivityItem, +} from "@portal/api/policies"; import "@portal/views/Policies.css"; interface PolicyDetailPanelProps { - /** The configured policy being viewed, or null when closed. */ policy: DecoratedPolicy | null; - /** Whether a lifecycle action (run/pause/delete) is in flight. */ busy?: boolean; onClose: () => void; onEdit: () => void; - onRun: () => void; + onRun?: () => void; onTogglePause: () => void; onDelete: () => void; + onRetry?: (item: PolicyActivityItem) => void; } -const ACTIVITY_TONE = { - enforced: "success", - flagged: "warning", - processing: "info", -} as const; +function CheckIcon() { + return ( + + + + ); +} + +function WarnIcon() { + return ( + + + + ); +} + +function SpinIcon() { + return ( + + + + ); +} + +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 ( + + + {message} + + + + ); +} -/** - * 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({ policy, busy = false, @@ -45,31 +105,36 @@ export function PolicyDetailPanel({ onRun, onTogglePause, onDelete, + onRetry, }: PolicyDetailPanelProps) { const { t } = useTranslation(); if (!policy) return null; const { category, config, state, steps, stats, activity } = policy; const isPaused = state.status === "paused"; 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 ( - - {policyIcon(category.icon)} - - {t("policies.detail.title", { category: category.label })} - - } - subtitle={config.summary} + title={category.label} footer={
{canDelete && ( @@ -84,15 +149,17 @@ export function PolicyDetailPanel({ {t("policies.detail.actions.delete")} )} - + {onRun && ( + + )} + )}
))} @@ -209,16 +311,6 @@ export function PolicyDetailPanel({ value={stats.activeFor} /> - - {state.scopeTypes.length > 0 && ( - - )}
); } diff --git a/frontend/portal/src/components/policies/PolicySetupWizard.tsx b/frontend/portal/src/components/policies/PolicySetupWizard.tsx index 133100f9a3..316f906e83 100644 --- a/frontend/portal/src/components/policies/PolicySetupWizard.tsx +++ b/frontend/portal/src/components/policies/PolicySetupWizard.tsx @@ -14,14 +14,16 @@ import { } from "@shared/components"; import { POLICY_DOC_TYPES, - POLICY_SOURCES, humanizeEndpoint, type CatalogueEntry, type PipelineStep, type PolicySetupResult, } from "@portal/api/policies"; +import { fetchSources } from "@portal/api/sources"; +import { useAsync } from "@portal/hooks/useAsync"; import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow"; import { policyIcon } from "@portal/components/policies/policyIcons"; +import { sourceTypeMeta } from "@portal/components/sources/sourceTypes"; import "@portal/views/Policies.css"; interface PolicySetupWizardProps { @@ -59,15 +61,27 @@ function resolveFieldValues( * round-trips); otherwise the category preset's default chain. Each preset step * starts enabled — the user toggles tools off in the workflow. */ +// Temporary: tracks which tools start disabled until the tool registry lands in +// the portal and can drive this via registry metadata or a defaultEnabled flag. +const DISABLED_BY_DEFAULT = new Set(["/api/v1/security/add-watermark"]); + function seedTools(entry: CatalogueEntry): ToolState[] { - const source = entry.policy?.steps?.length - ? entry.policy.steps - : entry.config.defaultOperations; - return source.map((s) => ({ - operation: s.operation, - enabled: true, - parameters: s.parameters, - })); + const savedSteps = entry.policy?.steps ?? []; + const savedByOp = new Map(savedSteps.map((s) => [s.operation, s])); + // Always use defaultOperations as the canonical list so tools added after a + // policy was first saved still appear when editing. + return entry.config.defaultOperations.map((s) => { + const saved = savedByOp.get(s.operation); + 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), ); const [sources, setSources] = useState( - 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( (policy?.state.scopeTypes.length ?? 0) > 0, ); const [scopeTypes, setScopeTypes] = useState( policy?.state.scopeTypes ?? [], ); - const [reviewerEmail, setReviewerEmail] = useState( - policy?.state.reviewerEmail ?? "you@acme.com", - ); + // TODO: replace with user-picker backed by GET /api/v1/user/users (UserSummary[]). + // 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">( policy?.state.outputMode ?? "new_version", ); const [outputName, setOutputName] = useState(policy?.state.outputName ?? ""); const [outputNamePosition, setOutputNamePosition] = useState< "prefix" | "suffix" | "auto-number" - >("suffix"); + >(policy?.state.outputNamePosition ?? "suffix"); const [runOn, setRunOn] = useState<"upload" | "export">( 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 [error, setError] = useState(null); @@ -180,6 +217,8 @@ function PolicySetupWizardBody({ outputName: outputName.trim(), outputNamePosition, runOn, + maxRetries, + retryDelayMinutes, steps, }); } catch { @@ -197,10 +236,7 @@ function PolicySetupWizardBody({ width="lg" title={ - + {policyIcon(category.icon)} {isEdit @@ -272,9 +308,7 @@ function PolicySetupWizardBody({ {humanizeEndpoint(tl.operation)} - - {tl.operation} - +
- {POLICY_SOURCES.map((src) => ( - - ))} + + )) + )}

@@ -392,107 +438,130 @@ function PolicySetupWizardBody({ {t("policies.wizard.output.heading")}

- - { - 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"), - }, - ]} - /> - - -
- + + { + 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"), + }, + ]} + /> + + +
+ setOutputName(e.target.value)} + /> + )} +
+
+ + )} + {/* TODO: reviewer user-picker goes here */} +

+ {t("policies.wizard.output.retries.heading")} +

+ setReviewerEmail(e.target.value)} + type="number" + value={String(maxRetries)} + onChange={(e) => + setMaxRetries(Math.max(0, Number(e.target.value) || 0)) + } + /> + + + + setRetryDelayMinutes(Math.max(0, Number(e.target.value) || 0)) + } />
diff --git a/frontend/portal/src/components/policies/storyFixtures.ts b/frontend/portal/src/components/policies/storyFixtures.ts index ec1c3ddffe..ddcdf46000 100644 --- a/frontend/portal/src/components/policies/storyFixtures.ts +++ b/frontend/portal/src/components/policies/storyFixtures.ts @@ -3,38 +3,53 @@ * policy built straight from the catalogue + seed data, so stories render the * 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 { POLICY_CATEGORIES, POLICY_CONFIG, - seedRuntime, + seedPolicies, + seedPolicyRuns, type DecoratedPolicy, + type PolicyState, } from "@portal/mocks/policies"; 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 { const category = POLICY_CATEGORIES.find((c) => c.id === 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 { category, config, - state: { - configured: true, - status: "active", - sources: ["editor"], - 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, + state, + steps: decoded.steps, + stats: runsToStats(policyRuns), + activity: runsToActivity(policyRuns), }; } diff --git a/frontend/portal/src/components/sources/sourceTypes.ts b/frontend/portal/src/components/sources/sourceTypes.ts index dd4b61470b..6b5c41ddeb 100644 --- a/frontend/portal/src/components/sources/sourceTypes.ts +++ b/frontend/portal/src/components/sources/sourceTypes.ts @@ -17,6 +17,7 @@ export interface SourceTypeMeta { const SOURCE_TYPE_META: Record = { folder: { labelKey: "sources.types.folder.label", icon: "⛁", tone: "blue" }, + editor: { labelKey: "sources.types.editor.label", icon: "✏", tone: "green" }, }; const UNKNOWN_TYPE_META: SourceTypeMeta = { diff --git a/frontend/portal/src/mocks/handlers/policies.ts b/frontend/portal/src/mocks/handlers/policies.ts index 521fcaf8d9..7a722d73e0 100644 --- a/frontend/portal/src/mocks/handlers/policies.ts +++ b/frontend/portal/src/mocks/handlers/policies.ts @@ -1,39 +1,35 @@ import { http, HttpResponse, delay } from "msw"; import { - POLICY_CATEGORIES, - POLICY_CONFIG, seedPolicies, - seedRuntime, - emptyRuntime, - type CatalogueEntry, - type DecoratedPolicy, - type PoliciesResponse, - type PoliciesSummary, - type Policy, - type PolicyRowStatus, - type PolicyRuntime, - type PolicyState, + seedPolicyRuns, + type WirePolicy, } from "@portal/mocks/policies"; +import type { PolicyRunView } from "@shared/policies/types"; /** * 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 - * backend (drop MSW and the same calls hit Stirling). These handlers mutate an - * in-memory store, so create/delete/run behave like a real backend within a - * session (see the notifications handler for the same stateful pattern). + * backend (drop MSW and the same calls hit Stirling). + * + * 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: Policy[] = seedPolicies(); -/** Runtime extras the wire record doesn't carry (scope, stats, activity). */ -let runtime: Record = seedRuntime(); +let store: WirePolicy[] = seedPolicies(); +let runs: PolicyRunView[] = seedPolicyRuns(); export function resetPoliciesStore( - seed?: Policy[], - seedRt?: Record, + seed?: WirePolicy[], + seedRuns?: PolicyRunView[], ): void { store = seed ? [...seed] : seedPolicies(); - runtime = seedRt ? { ...seedRt } : seedRuntime(); + runs = seedRuns ? [...seedRuns] : seedPolicyRuns(); } let idCounter = 0; @@ -42,78 +38,21 @@ function nextId(categoryId: string): string { return `pol_${categoryId}_${Date.now().toString(36)}_${idCounter}`; } -/** Derive the display status from the wire `enabled` flag. */ -function rowStatus(policy: Policy): PolicyRowStatus { - 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(); - 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 }; +function categoryId(wire: WirePolicy): string { + return (wire.output?.options?.categoryId as string | undefined) ?? ""; } export const policiesHandlers = [ - // List — the catalogue (categories + configs + configured policies). http.get("/api/v1/policies", async () => { 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 }) => { await delay(120); const policy = store.find((p) => p.id === params.id); @@ -121,17 +60,17 @@ export const policiesHandlers = [ return HttpResponse.json(policy); }), - // Create or update — a blank id is assigned (create) or matched (update). - // One policy per category: a create for a category that already has one - // replaces it, matching the editor's "one policy per category, ever". + // Create or update — one policy per category: a create for a category that + // already has one replaces it, matching the editor's contract. http.post("/api/v1/policies", async ({ request }) => { 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 ? store.find((p) => p.id === incoming.id) - : store.find((p) => p.categoryId === incoming.categoryId); - const id = existing?.id ?? nextId(incoming.categoryId); - const saved: Policy = { + : store.find((p) => categoryId(p) === catId); + const id = existing?.id ?? nextId(catId); + const saved: WirePolicy = { ...incoming, id, owner: existing?.owner ?? "you@acme.com", @@ -139,45 +78,16 @@ export const policiesHandlers = [ store = existing ? store.map((p) => (p.id === id ? saved : p)) : [...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); }), - // Delete a stored policy by id. http.delete("/api/v1/policies/:id", async ({ params }) => { await delay(120); const id = String(params.id); - const existed = store.some((p) => p.id === id); - if (!existed) return new HttpResponse(null, { status: 404 }); + if (!store.some((p) => p.id === id)) + return new HttpResponse(null, { status: 404 }); store = store.filter((p) => p.id !== id); - delete runtime[id]; + runs = runs.filter((r) => r.policyId !== id); 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 }); - }), ]; diff --git a/frontend/portal/src/mocks/policies.ts b/frontend/portal/src/mocks/policies.ts index 76339e5acd..27d138f297 100644 --- a/frontend/portal/src/mocks/policies.ts +++ b/frontend/portal/src/mocks/policies.ts @@ -1,200 +1,84 @@ /** * Policies fixtures and the canonical TS model the portal shares with them. * - * The model mirrors the editor + backend policy contract so the portal's - * "set up a policy" flow is plug-and-play against the real `/api/v1/policies` - * API. A policy is a stored automation: an ordered chain of tool steps (each - * step's `operation` is a Stirling endpoint path) plus an output destination, - * fired automatically by a trigger (editor upload/export) over a set of - * sources. The catalogue groups policies by category, each category carrying a - * `PolicyConfigDef` (summary, rules, fields, default tool chain) the setup flow - * builds from. + * Wire types (`WirePolicy`, `WirePipelineStep`) come from the shared codec + * layer and match the backend record exactly. Catalogue and UI types + * (`PolicyCategory`, `PolicyConfigDef`, `PolicyState`, …) are portal-only: + * the backend has no "category" concept — `categoryId` rides in + * `output.options`. The catalogue assembles client-side in `api/policies.ts` + * from the decoded wire records + these static definitions. * - * The wire types (`Policy`, `PipelineStep`) match the backend records exactly; - * 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. + * api/policies.ts re-exports everything; components never reach in here. */ -/* ──────────────────────────────────────────────────────────────────────── */ -/* Backend wire model — matches Policy.java / PipelineStep.java exactly */ -/* ──────────────────────────────────────────────────────────────────────── */ +import type { WirePipelineStep, WirePolicy } from "@shared/policies/types"; +import type { PolicyRunView } from "@shared/policies/types"; -/** - * A single tool invocation in a policy's pipeline. `operation` is a Stirling - * endpoint path (e.g. `/api/v1/security/auto-redact`); `parameters` are the - * scalar form fields that endpoint accepts. `fileParameters` binds a tool's - * named file field to an asset key in a run's supporting-file store. - */ -export interface PipelineStep { - operation: string; - parameters: Record; - fileParameters?: Record; -} - -/** 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; -} +export type { + PolicyActivityItem, + PolicyDecodedState, + PolicyRunStatus, + PolicyRunView, + PolicyStats, + WireOutputOptions, + WireOutputSpec, + WirePipelineStep, + WirePolicy, +} from "@shared/policies/types"; /* ──────────────────────────────────────────────────────────────────────── */ -/* 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"; -/** A configurable field within a policy's settings. */ export type PolicyFieldType = "toggle" | "select" | "chips" | "text"; export interface PolicyField { label: string; key: string; type: PolicyFieldType; - /** Default value: boolean (toggle), string (select/text), string[] (chips). */ value: boolean | string | string[]; - /** Options for select/chips. */ 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 { id: string; label: string; - /** Icon key the portal renders (not a component — the portal owns glyphs). */ icon: string; - /** Visual tone for the category's icon chip. */ tone: "neutral" | "blue" | "purple" | "green" | "amber" | "red"; - /** Long description shown in the setup flow. */ desc: string; - /** Drives the "Set up Classification" affordance (doc-type narrowing). */ providesClassification?: boolean; - /** Locked "Coming soon" — can't be opened or configured. */ comingSoon?: boolean; } -/** The narrative + field configuration backing a category. */ export interface PolicyConfigDef { - /** One-line summary of what the policy enforces. */ summary: string; - /** Pipeline-like rule chips shown in the "Enforces" section. */ rules: string[]; - /** Human label for the scope this policy applies to. */ scopeLabel: string; - /** Editable policy-level settings fields. */ fields: PolicyField[]; - /** - * 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[]; + defaultOperations: WirePipelineStep[]; } -/** 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 { configured: boolean; status: PolicyStatus; - /** Selected sources (ids from {@link POLICY_SOURCES}). */ sources: string[]; - /** When non-empty, narrows the policy to these document types. */ scopeTypes: string[]; - /** Email low-confidence enforcements are routed to. */ reviewerEmail: string; - /** Saved field values, keyed by field key (overrides the definition default). */ fieldValues: Record; - /** How a run's output is delivered. Defaults to "new_version". */ outputMode?: "new_file" | "new_version"; - /** Rename rule for the output. Empty keeps the input filename. */ outputName?: string; - /** When the policy runs. Defaults to "upload". */ + outputNamePosition?: "prefix" | "suffix" | "auto-number"; runOn?: "upload" | "export"; - /** Backend record id once persisted; used to update/delete/run it. */ + maxRetries?: number; + retryDelayMinutes?: number; backendId?: string; - /** A shipped catalogue policy (configurable but not deletable). */ isDefault?: boolean; } -/** What the setup flow hands back on submit — collected settings + built steps. */ export interface PolicySetupResult { fieldValues: Record; sources: string[]; @@ -204,57 +88,40 @@ export interface PolicySetupResult { outputName: string; outputNamePosition: "prefix" | "suffix" | "auto-number"; runOn: "upload" | "export"; - /** The configured tool chain as backend pipeline steps. */ - steps: PipelineStep[]; + maxRetries: number; + 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 { category: PolicyCategory; config: PolicyConfigDef; state: PolicyState; - /** The policy's configured steps (drives the detail "Enforces" flow). */ - steps: PipelineStep[]; - stats: PolicyStats; - activity: PolicyActivityItem[]; + steps: WirePipelineStep[]; + stats: import("@shared/policies/types").PolicyStats; + activity: import("@shared/policies/types").PolicyActivityItem[]; } -/** Catalogue strip totals shown above the cards. */ export interface PoliciesSummary { - /** Policies currently active (enabled). */ active: number; - /** Policies configured but paused. */ paused: number; - /** Categories available to configure. */ categories: number; - /** Documents enforced across all active policies. */ docsEnforced: number; } -/** The `GET /api/v1/policies` response, in the portal's catalogue shape. */ export interface PoliciesResponse { summary: PoliciesSummary; - /** Every catalogue category, each with its definition + (optional) state. */ catalogue: CatalogueEntry[]; } -/** One catalogue row: a category, its definition, and its current state. */ export interface CatalogueEntry { category: PolicyCategory; config: PolicyConfigDef; - /** The configured policy's runtime view, or null when not yet set up. */ policy: DecoratedPolicy | null; } /* ──────────────────────────────────────────────────────────────────────── */ /* 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 = { @@ -266,7 +133,6 @@ export const TOOL_ENDPOINTS: Record = { compress: "/api/v1/misc/compress-pdf", }; -/** A friendly label for an endpoint path (for the detail "Enforces" chips). */ export const ENDPOINT_LABELS: Record = { "/api/v1/security/auto-redact": "Redact PII", "/api/v1/security/sanitize-pdf": "Remove JavaScript", @@ -276,7 +142,6 @@ export const ENDPOINT_LABELS: Record = { "/api/v1/misc/compress-pdf": "Compress", }; -/** "/api/v1/security/auto-redact" → "Auto Redact" — fallback humanisation. */ export function humanizeEndpoint(path: string): string { if (ENDPOINT_LABELS[path]) return ENDPOINT_LABELS[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 */ -/* Modelled on the editor's policyDefinitions. PII redact regexes are the */ -/* precise patterns the /auto-redact endpoint matches (wordsToRedact). */ +/* Catalogue definitions */ /* ──────────────────────────────────────────────────────────────────────── */ -/** PII regexes seeded into a Security policy's redact step (SSN + cards). */ const DEFAULT_PII_PATTERNS: string[] = [ - "\\b(?!000|666|9\\d{2})\\d{3}([- ])(?!00)\\d{2}\\1(?!0000)\\d{4}\\b", // SSN - "\\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(?!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", ]; export const POLICY_CATEGORIES: PolicyCategory[] = [ @@ -371,10 +233,8 @@ export const POLICY_CONFIG: Record = { security: { summary: "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", - // 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: [ { operation: TOOL_ENDPOINTS.redact, @@ -395,9 +255,14 @@ export const POLICY_CONFIG: Record = { 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: [], }, compliance: { @@ -482,45 +347,6 @@ export const POLICY_CONFIG: Record = { }, }; -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[] = [ "Contracts", "Invoices", @@ -533,80 +359,86 @@ export const POLICY_DOC_TYPES: string[] = [ ]; /* ──────────────────────────────────────────────────────────────────────── */ -/* Seed policies — a few configured policies in the wire shape, so the store */ -/* behaves like a backend that already has policies set up. */ +/* Seed data — real backend wire format */ /* ──────────────────────────────────────────────────────────────────────── */ -/** The shipped default policies the handlers seed the store with. */ -export function seedPolicies(): Policy[] { +export function seedPolicies(): WirePolicy[] { return [ { id: "pol_security_default", name: "Security Policy", owner: "security@acme.com", enabled: true, - trigger: { event: "upload" }, - sources: [{ source: "editor" }], + trigger: null, steps: POLICY_CONFIG.security.defaultOperations, - output: { mode: "new_version", name: "", namePosition: "suffix" }, - categoryId: "security", + output: { + 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: {}, + }, + }, }, ]; } -/** - * Per-policy runtime extras keyed by policy id — the parts the wire record - * doesn't carry (collected field values, scope, derived stats + activity). - * In a real backend these would be derived server-side from the user's files. - */ -export interface PolicyRuntime { - scopeTypes: string[]; - reviewerEmail: string; - fieldValues: Record; - stats: PolicyStats; - activity: PolicyActivityItem[]; - isDefault?: boolean; -} +const NOW = Date.now(); +const M = 60000; +const H = 3600000; +const D = 86400000; -export function seedRuntime(): Record { - return { - pol_security_default: { - scopeTypes: [], - reviewerEmail: "security@acme.com", - fieldValues: {}, - isDefault: true, - stats: { enforced: 4821, dataProcessed: "2.3 GB", activeFor: "34d" }, - activity: [ - { - doc: "Q2-vendor-agreement.pdf", - action: "Redacted 6 PII matches • JavaScript stripped", - 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", - }, - ], +/** Seed `PolicyRunView` records that drive the activity feed + stats. */ +export function seedPolicyRuns(): PolicyRunView[] { + return [ + { + runId: "run_001", + policyId: "pol_security_default", + status: "COMPLETED", + currentStep: 2, + stepCount: 2, + error: null, + outputs: [{ fileId: "f1", fileName: "Q2-vendor-agreement.pdf" }], + createdAt: NOW - 12 * M, }, - }; -} - -/** Empty stats/activity for a freshly-configured policy with no runs yet. */ -export function emptyRuntime(reviewerEmail = "you@acme.com"): PolicyRuntime { - return { - scopeTypes: [], - reviewerEmail, - fieldValues: {}, - stats: { enforced: 0, dataProcessed: "0 B", activeFor: "—" }, - activity: [], - }; + { + runId: "run_002", + policyId: "pol_security_default", + status: "FAILED", + currentStep: 1, + stepCount: 2, + error: "Low-confidence match — routed for review", + outputs: [{ fileId: "f2", fileName: "patient-intake-0481.pdf" }], + createdAt: NOW - 1 * H, + }, + { + runId: "run_003", + policyId: "pol_security_default", + status: "RUNNING", + currentStep: 1, + stepCount: 2, + error: null, + outputs: [{ fileId: "f3", fileName: "invoice-7782.pdf" }], + createdAt: NOW - 2 * M, + }, + // Older completed runs for stats + ...Array.from({ length: 4818 }, (_, i) => ({ + runId: `run_old_${i}`, + policyId: "pol_security_default", + status: "COMPLETED" as const, + currentStep: 2, + stepCount: 2, + error: null, + outputs: [] as { fileId: string; fileName: string }[], + createdAt: NOW - (34 * D + i * 10 * M), + })), + ]; } diff --git a/frontend/portal/src/views/Policies.css b/frontend/portal/src/views/Policies.css index d40df402bd..5744b2ff42 100644 --- a/frontend/portal/src/views/Policies.css +++ b/frontend/portal/src/views/Policies.css @@ -31,21 +31,16 @@ /* Category 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; 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; } @@ -53,10 +48,47 @@ opacity: 0.7; } -.portal-policies__card-head { +.portal-policies__card-identity { display: flex; - align-items: flex-start; - gap: 0.625rem; + flex-direction: column; + 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 { @@ -66,41 +98,8 @@ width: 2rem; height: 2rem; flex-shrink: 0; - border-radius: var(--radius-md); - font-size: 1rem; -} - -.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; + font-size: 1.125rem; + color: var(--color-text-2); } .portal-policies__card-title { @@ -110,50 +109,6 @@ 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 */ .portal-policies__wizard-title { display: inline-flex; @@ -196,6 +151,13 @@ 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 { display: flex; flex-direction: column; @@ -225,17 +187,6 @@ 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 */ .portal-policies__sources { display: grid; @@ -251,12 +202,12 @@ .portal-policies__source { display: flex; - align-items: flex-start; + align-items: center; gap: 0.5rem; padding: 0.625rem; text-align: left; background: var(--color-surface); - border: 1px solid var(--color-border); + border: 1.5px solid var(--color-border); border-radius: var(--radius-md); cursor: pointer; transition: @@ -264,6 +215,22 @@ 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 { background: var(--color-bg-hover); } @@ -273,12 +240,26 @@ 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 { font-size: 1rem; line-height: 1.2; + flex-shrink: 0; color: var(--color-text-3); } +.portal-policies__source--on .portal-policies__source-icon { + color: var(--color-blue); +} + .portal-policies__source-text { display: flex; flex-direction: column; @@ -338,8 +319,13 @@ .portal-policies__detail-status { display: flex; align-items: center; - gap: 0.625rem; - margin-bottom: 0.25rem; + gap: 0.5rem; + margin-bottom: 0.625rem; +} + +.portal-policies__detail-sep { + color: var(--color-text-5); + font-size: 0.75rem; } .portal-policies__detail-meta { @@ -347,28 +333,31 @@ 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 { color: var(--color-text-5); font-size: 0.75rem; } -.portal-policies__enforce-note { - margin: 0.75rem 0 0; - font-size: 0.75rem; +.portal-policies__detail-inline { + display: flex; + 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); + 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; } @@ -384,22 +373,32 @@ border-top: 1px solid var(--color-border-light); } -.portal-policies__activity-dot { - width: 0.5rem; - height: 0.5rem; +.portal-policies__activity-icon { flex-shrink: 0; - margin-top: 0.375rem; - border-radius: 50%; + margin-top: 0.125rem; + display: flex; + align-items: flex-start; + padding-top: 0.125rem; } -.portal-policies__activity-dot--success { - background: var(--color-green); +.portal-policies__activity-icon--success { + color: var(--color-green); } -.portal-policies__activity-dot--warning { - background: var(--color-amber); +.portal-policies__activity-icon--warning { + color: var(--color-amber); } -.portal-policies__activity-dot--info { - background: var(--color-blue); +.portal-policies__activity-icon--info { + 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 { @@ -427,6 +426,29 @@ 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 */ .portal-policies__detail-stats { display: grid; diff --git a/frontend/portal/src/views/Policies.tsx b/frontend/portal/src/views/Policies.tsx index a71190e919..e9eb33b95d 100644 --- a/frontend/portal/src/views/Policies.tsx +++ b/frontend/portal/src/views/Policies.tsx @@ -1,15 +1,18 @@ import { useCallback, useState } from "react"; 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 { + buildWireFromSetup, + buildWireFromState, deletePolicy, fetchPolicies, - runPolicy, savePolicy, + POLICY_CATEGORIES, + POLICY_CONFIG, type CatalogueEntry, type PoliciesResponse, - type Policy, type PolicySetupResult, } from "@portal/api/policies"; 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 "@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() { 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 state = useAsync(() => fetchPolicies(), [version]); - const { data, loading } = state; + const { data, loading, error: fetchError } = 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(null); const [wizard, setWizard] = useState(null); const [busy, setBusy] = useState(false); + const [pageError, setPageError] = useState(null); const catalogue = data?.catalogue ?? []; 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) { if (entry.policy) setDetail(entry); else setWizard(entry); @@ -72,50 +60,39 @@ export function Policies() { entry: CatalogueEntry, result: PolicySetupResult, ) { - await savePolicy(toWirePolicy(entry, result)); - setWizard(null); - setDetail(null); - refetch(); + setPageError(null); + try { + await savePolicy(buildWireFromSetup(entry, result)); + setWizard(null); + setDetail(null); + refetch(); + } catch (e) { + setPageError(errorMessage(e)); + } } async function runLifecycle(action: () => Promise) { if (busy) return; + setPageError(null); setBusy(true); try { await action(); setDetail(null); refetch(); + } catch (e) { + setPageError(errorMessage(e)); } finally { setBusy(false); } } - function handleRun() { - const id = detail?.policy?.state.backendId; - if (id) void runLifecycle(() => runPolicy(id)); - } - function handleTogglePause() { const entry = detail; const policy = entry?.policy; if (!entry || !policy?.state.backendId) return; - // Pause/resume is a re-save with the enabled flag flipped (the backend has - // no dedicated endpoint — every mutation routes through POST /policies). + const enabled = policy.state.status === "paused"; void runLifecycle(() => - savePolicy({ - 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, - }), + savePolicy(buildWireFromState(entry, policy, enabled)), ); } @@ -124,7 +101,6 @@ export function Policies() { if (id) void runLifecycle(() => deletePolicy(id)); } - // Reopen the wizard for the policy currently shown in the detail panel. function handleEdit() { if (detail) { setWizard(detail); @@ -139,19 +115,34 @@ export function Policies() {

{t("policies.subtitle")}

+ {pageError && } + {isLoading && (
{Array.from({ length: 5 }).map((_, i) => ( - + ))}
)} - {!isLoading && catalogue.length > 0 && ( + {!isLoading && fetchError && ( + + {t("policies.offline.retry")} + + } + /> + )} + + {!isLoading && !fetchError && (
- {catalogue.map((entry) => ( + {displayCatalogue.map((entry) => ( setDetail(null)} onEdit={handleEdit} - onRun={handleRun} onTogglePause={handleTogglePause} onDelete={handleDelete} /> diff --git a/frontend/portal/vitest.config.ts b/frontend/portal/vitest.config.ts index a34f840b89..172c551f37 100644 --- a/frontend/portal/vitest.config.ts +++ b/frontend/portal/vitest.config.ts @@ -23,12 +23,19 @@ export default defineConfig({ "@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: { globals: true, environment: "jsdom", setupFiles: ["./src/setupTests.ts"], css: false, - include: ["src/**/*.test.{ts,tsx}"], + include: ["src/**/*.test.{ts,tsx}", "../shared/**/*.test.ts"], testTimeout: 10000, hookTimeout: 10000, }, diff --git a/frontend/shared/policies/codec.test.ts b/frontend/shared/policies/codec.test.ts new file mode 100644 index 0000000000..e4b2647ec9 --- /dev/null +++ b/frontend/shared/policies/codec.test.ts @@ -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).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).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).fieldValues; + expect(fromWirePolicy(wire).fieldValues).toEqual({}); + }); +}); diff --git a/frontend/shared/policies/codec.ts b/frontend/shared/policies/codec.ts new file mode 100644 index 0000000000..3a79af7b5c --- /dev/null +++ b/frontend/shared/policies/codec.ts @@ -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 : [], + }; +} diff --git a/frontend/shared/policies/runs.test.ts b/frontend/shared/policies/runs.test.ts new file mode 100644 index 0000000000..ed57b5fe8c --- /dev/null +++ b/frontend/shared/policies/runs.test.ts @@ -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"); + }); +}); diff --git a/frontend/shared/policies/runs.ts b/frontend/shared/policies/runs.ts new file mode 100644 index 0000000000..209dccd13d --- /dev/null +++ b/frontend/shared/policies/runs.ts @@ -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) : "—", + }; +} diff --git a/frontend/shared/policies/types.ts b/frontend/shared/policies/types.ts new file mode 100644 index 0000000000..ef01397249 --- /dev/null +++ b/frontend/shared/policies/types.ts @@ -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; + fileParameters?: Record; +} + +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; +} + +export interface WireOutputSpec { + type: "inline"; + options: Partial; +} + +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; + 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"; +} diff --git a/scripts/find-free-port.ps1 b/scripts/find-free-port.ps1 index 241117a148..7c102d29fd 100644 --- a/scripts/find-free-port.ps1 +++ b/scripts/find-free-port.ps1 @@ -1,24 +1,24 @@ # 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 -# emits a random free port in 20000-49999. Probes by attempting to bind a -# TcpListener on loopback. Tracks picks within this run so outputs are -# guaranteed distinct from each other. +# emits a random free port in 20000-49999. Uses Get-NetTCPConnection to read +# the OS socket table directly — more reliable than TcpListener binding on +# 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) $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 { param([int]$Port) if ($script:picked -contains $Port) { return $false } - try { - $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $Port) - $listener.Start() - $listener.Stop() - return $true - } catch { - return $false - } + return -not $usedPorts.Contains($Port) } function Get-RandomFreePort {