From 0beff1a92b6dcc6ebe39c802d9c1de0322276b8a Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:24:14 +0100 Subject: [PATCH 1/3] feat(shared): make @shared the single home for brand logo assets (#6714) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Makes `@shared` the single home for the Stirling brand logo assets. Moves the editor's two logo sets — `classic-logo` + `modern-logo` (22 files: marks, wordmarks, favicons, login headers, PNGs) — out of `editor/public/` into `shared/assets/brand/`, and adds a Storybook **Brand/Logos** gallery. ## Why this shape (not a plain move) The editor serves logos by **URL** from `public/` and switches `classic`/`modern` by a **user preference** (`useLogoAssets`, `manifest.json` / `manifest-classic.json`, `index.html` favicon links). Rewiring all that to module imports would be a large, risky change to the variant system. Instead the editor keeps its variant system **unchanged** and just sources the files from shared: `vite-plugin-static-copy` copies `shared/assets/brand/{classic,modern}-logo/*` back to the served `/{classic,modern}-logo` paths (the editor already uses this plugin for pdfium/pdfjs assets). Single source of truth in shared, zero editor code/manifest/markup changes. ## Verified - **Build:** editor builds with both sets present at `dist/{modern,classic}-logo/`; `manifest.json` + favicon refs resolve. - **Dev:** the vite dev server serves the bridged paths — `/modern-logo/logo512.png`, `/modern-logo/StirlingPDFLogoNoTextDark.svg`, `/classic-logo/favicon.ico` all return **HTTP 200** (the plugin's dev middleware). - Typecheck clean on core/proprietary/saas; prettier clean; `storybook build` succeeds with the `Brand/Logos` gallery bundled. - The portal's existing `@shared/assets` brand imports are untouched. ## Follow-ups (not in this PR) - **Dedup:** `shared/assets/stirling-mark-*.svg` is byte-identical to `brand/modern-logo/StirlingPDFLogoNoTextDark.svg`, and `stirling-pdf-logo-*` is a near-twin of the modern wordmark. Reconciling these (and re-pointing the portal) needs a designer eye on which wordmark is canonical, so it's left out here to avoid changing the portal's rendered logo. - `editor/src/logo.svg` appears unused (no references) — candidate for deletion separately. --- .../src/core/hooks/useLogoAssets.test.ts | 7 +- .../editor/src/proprietary/routes/Login.tsx | 2 +- .../editor/src/proprietary/routes/Signup.tsx | 2 +- frontend/editor/src/saas/routes/Login.tsx | 2 +- .../editor/src/saas/routes/OAuthConsent.tsx | 2 +- frontend/editor/src/saas/routes/Signup.tsx | 2 +- frontend/editor/vite.config.ts | 11 ++ .../portal/src/components/LoginScreen.tsx | 2 +- frontend/portal/src/components/Sidebar.tsx | 4 +- frontend/shared/assets/Brand.stories.tsx | 106 ++++++++++++++++++ .../assets/brand}/classic-logo/Firstpage.png | Bin .../classic-logo/StirlingPDFLogoBlackText.svg | 0 .../classic-logo/StirlingPDFLogoGreyText.svg | 0 .../StirlingPDFLogoNoTextDark.svg | 0 .../StirlingPDFLogoNoTextLight.svg | 0 .../classic-logo/StirlingPDFLogoWhiteText.svg | 0 .../assets/brand}/classic-logo/favicon.ico | Bin .../brand}/classic-logo/logo-tooltip.svg | 0 .../assets/brand}/classic-logo/logo192.png | Bin .../assets/brand}/classic-logo/logo512.png | Bin .../assets/brand}/modern-logo/Firstpage.png | Bin .../modern-logo/LoginDarkModeHeader.svg | 0 .../modern-logo}/LoginLightModeHeader.svg | 0 .../modern-logo/StirlingPDFLogoBlackText.svg | 0 .../modern-logo/StirlingPDFLogoGreyText.svg | 0 .../modern-logo/StirlingPDFLogoNoTextDark.svg | 0 .../StirlingPDFLogoNoTextLight.svg | 0 .../modern-logo/StirlingPDFLogoWhiteText.svg | 0 .../assets/brand}/modern-logo/favicon.ico | Bin .../brand}/modern-logo/logo-tooltip.svg | 0 .../assets/brand}/modern-logo/logo192.png | Bin .../assets/brand}/modern-logo/logo512.png | Bin frontend/shared/assets/stirling-mark-dark.svg | 4 - .../shared/assets/stirling-mark-light.svg | 4 - .../shared/assets/stirling-pdf-logo-dark.svg | 4 - .../shared/assets/stirling-pdf-logo-light.svg | 4 - 36 files changed, 131 insertions(+), 25 deletions(-) create mode 100644 frontend/shared/assets/Brand.stories.tsx rename frontend/{editor/public => shared/assets/brand}/classic-logo/Firstpage.png (100%) rename frontend/{editor/public => shared/assets/brand}/classic-logo/StirlingPDFLogoBlackText.svg (100%) rename frontend/{editor/public => shared/assets/brand}/classic-logo/StirlingPDFLogoGreyText.svg (100%) rename frontend/{editor/public => shared/assets/brand}/classic-logo/StirlingPDFLogoNoTextDark.svg (100%) rename frontend/{editor/public => shared/assets/brand}/classic-logo/StirlingPDFLogoNoTextLight.svg (100%) rename frontend/{editor/public => shared/assets/brand}/classic-logo/StirlingPDFLogoWhiteText.svg (100%) rename frontend/{editor/public => shared/assets/brand}/classic-logo/favicon.ico (100%) rename frontend/{editor/public => shared/assets/brand}/classic-logo/logo-tooltip.svg (100%) rename frontend/{editor/public => shared/assets/brand}/classic-logo/logo192.png (100%) rename frontend/{editor/public => shared/assets/brand}/classic-logo/logo512.png (100%) rename frontend/{editor/public => shared/assets/brand}/modern-logo/Firstpage.png (100%) rename frontend/{editor/public => shared/assets/brand}/modern-logo/LoginDarkModeHeader.svg (100%) rename frontend/shared/assets/{login => brand/modern-logo}/LoginLightModeHeader.svg (100%) rename frontend/{editor/public => shared/assets/brand}/modern-logo/StirlingPDFLogoBlackText.svg (100%) rename frontend/{editor/public => shared/assets/brand}/modern-logo/StirlingPDFLogoGreyText.svg (100%) rename frontend/{editor/public => shared/assets/brand}/modern-logo/StirlingPDFLogoNoTextDark.svg (100%) rename frontend/{editor/public => shared/assets/brand}/modern-logo/StirlingPDFLogoNoTextLight.svg (100%) rename frontend/{editor/public => shared/assets/brand}/modern-logo/StirlingPDFLogoWhiteText.svg (100%) rename frontend/{editor/public => shared/assets/brand}/modern-logo/favicon.ico (100%) rename frontend/{editor/public => shared/assets/brand}/modern-logo/logo-tooltip.svg (100%) rename frontend/{editor/public => shared/assets/brand}/modern-logo/logo192.png (100%) rename frontend/{editor/public => shared/assets/brand}/modern-logo/logo512.png (100%) delete mode 100644 frontend/shared/assets/stirling-mark-dark.svg delete mode 100644 frontend/shared/assets/stirling-mark-light.svg delete mode 100644 frontend/shared/assets/stirling-pdf-logo-dark.svg delete mode 100644 frontend/shared/assets/stirling-pdf-logo-light.svg diff --git a/frontend/editor/src/core/hooks/useLogoAssets.test.ts b/frontend/editor/src/core/hooks/useLogoAssets.test.ts index 3d314ba60b..b22701dedd 100644 --- a/frontend/editor/src/core/hooks/useLogoAssets.test.ts +++ b/frontend/editor/src/core/hooks/useLogoAssets.test.ts @@ -10,6 +10,11 @@ import type { LogoVariant } from "@app/services/preferencesService"; */ describe("useLogoAssets - Logo Asset Files", () => { const publicDir = path.resolve(__dirname, "../../../public"); + // Brand logo assets live in the shared design system; the editor's vite + // config copies shared/assets/brand//* into the served root at build + // time (see viteStaticCopy in editor/vite.config.ts), so useLogoAssets can + // keep referencing them by their public-URL path. Validate them at source. + const brandDir = path.resolve(__dirname, "../../../../shared/assets/brand"); // All asset files that useLogoAssets references const requiredAssets = [ @@ -27,7 +32,7 @@ describe("useLogoAssets - Logo Asset Files", () => { describe.each(logoVariants)("%s logo variant", (variant) => { const folder = LOGO_FOLDER_BY_VARIANT[variant]; - const folderPath = path.join(publicDir, folder); + const folderPath = path.join(brandDir, folder); test(`folder "${folder}" should exist`, () => { expect(fs.existsSync(folderPath)).toBe(true); diff --git a/frontend/editor/src/proprietary/routes/Login.tsx b/frontend/editor/src/proprietary/routes/Login.tsx index 86bedb1934..054aa0a47d 100644 --- a/frontend/editor/src/proprietary/routes/Login.tsx +++ b/frontend/editor/src/proprietary/routes/Login.tsx @@ -22,7 +22,7 @@ import { import SpringLoginForm from "@shared/auth/ui/SpringLoginForm"; import { useSpringLogin } from "@shared/auth/ui/useSpringLogin"; import LoggedInState from "@app/routes/login/LoggedInState"; -import loginHeader from "@shared/assets/login/LoginLightModeHeader.svg"; +import loginHeader from "@shared/assets/brand/modern-logo/LoginLightModeHeader.svg"; export default function Login() { const navigate = useNavigate(); diff --git a/frontend/editor/src/proprietary/routes/Signup.tsx b/frontend/editor/src/proprietary/routes/Signup.tsx index 49481c9ce3..8b9fc87d71 100644 --- a/frontend/editor/src/proprietary/routes/Signup.tsx +++ b/frontend/editor/src/proprietary/routes/Signup.tsx @@ -16,7 +16,7 @@ import { SignupFieldErrors, } from "@app/routes/signup/SignupFormValidation"; import { useAuthService } from "@app/routes/signup/AuthService"; -import loginHeader from "@shared/assets/login/LoginLightModeHeader.svg"; +import loginHeader from "@shared/assets/brand/modern-logo/LoginLightModeHeader.svg"; export default function Signup() { const navigate = useNavigate(); diff --git a/frontend/editor/src/saas/routes/Login.tsx b/frontend/editor/src/saas/routes/Login.tsx index de98ccf482..dc52096dba 100644 --- a/frontend/editor/src/saas/routes/Login.tsx +++ b/frontend/editor/src/saas/routes/Login.tsx @@ -19,7 +19,7 @@ import ErrorMessage from "@shared/auth/ui/ErrorMessage"; import EmailPasswordForm from "@app/routes/login/EmailPasswordForm"; import OAuthButtons from "@app/routes/login/OAuthButtons"; import LoggedInState from "@app/routes/login/LoggedInState"; -import loginHeader from "@shared/assets/login/LoginLightModeHeader.svg"; +import loginHeader from "@shared/assets/brand/modern-logo/LoginLightModeHeader.svg"; export default function Login() { const navigate = useNavigate(); diff --git a/frontend/editor/src/saas/routes/OAuthConsent.tsx b/frontend/editor/src/saas/routes/OAuthConsent.tsx index c02edfa934..8aa42cc430 100644 --- a/frontend/editor/src/saas/routes/OAuthConsent.tsx +++ b/frontend/editor/src/saas/routes/OAuthConsent.tsx @@ -8,7 +8,7 @@ import "@shared/auth/ui/auth.css"; import "@app/routes/authShared/saas-auth.css"; import { withBasePath } from "@app/constants/app"; import ErrorMessage from "@shared/auth/ui/ErrorMessage"; -import loginHeader from "@shared/assets/login/LoginLightModeHeader.svg"; +import loginHeader from "@shared/assets/brand/modern-logo/LoginLightModeHeader.svg"; /** * OAuth 2.1 consent screen for the Supabase OAuth server (used by MCP clients diff --git a/frontend/editor/src/saas/routes/Signup.tsx b/frontend/editor/src/saas/routes/Signup.tsx index 7327e04eb3..430fd54863 100644 --- a/frontend/editor/src/saas/routes/Signup.tsx +++ b/frontend/editor/src/saas/routes/Signup.tsx @@ -19,7 +19,7 @@ import { SignupFieldErrors, } from "@app/routes/signup/SignupFormValidation"; import { useAuthService } from "@app/routes/signup/AuthService"; -import loginHeader from "@shared/assets/login/LoginLightModeHeader.svg"; +import loginHeader from "@shared/assets/brand/modern-logo/LoginLightModeHeader.svg"; export default function Signup() { const navigate = useNavigate(); diff --git a/frontend/editor/vite.config.ts b/frontend/editor/vite.config.ts index 7ac9af5060..b2077ccc96 100644 --- a/frontend/editor/vite.config.ts +++ b/frontend/editor/vite.config.ts @@ -271,6 +271,17 @@ export default defineConfig(async ({ mode }) => { src: "../node_modules/pdfjs-dist/standard_fonts/*", dest: "pdfjs/standard_fonts", }, + { + // Brand assets live in the shared design system; the editor serves + // them by URL per variant, so copy each set to the /{variant}-logo + // path its manifests, index.html and useLogoAssets resolve against. + src: "../shared/assets/brand/classic-logo/*", + dest: "classic-logo", + }, + { + src: "../shared/assets/brand/modern-logo/*", + dest: "modern-logo", + }, ], }), compressStaticCopyPlugin(), diff --git a/frontend/portal/src/components/LoginScreen.tsx b/frontend/portal/src/components/LoginScreen.tsx index 67692b3c3c..a44c5aa6e0 100644 --- a/frontend/portal/src/components/LoginScreen.tsx +++ b/frontend/portal/src/components/LoginScreen.tsx @@ -7,7 +7,7 @@ import SpringLoginForm from "@shared/auth/ui/SpringLoginForm"; import { useSpringLogin } from "@shared/auth/ui/useSpringLogin"; import "@shared/auth/ui/auth-theme.css"; import "@shared/auth/ui/auth.css"; -import loginHeader from "@shared/assets/login/LoginLightModeHeader.svg"; +import loginHeader from "@shared/assets/brand/modern-logo/LoginLightModeHeader.svg"; /** * Full-screen login shown by the portal's auth gate. Renders the same screen as diff --git a/frontend/portal/src/components/Sidebar.tsx b/frontend/portal/src/components/Sidebar.tsx index 48e7ea0b04..a94c780e9d 100644 --- a/frontend/portal/src/components/Sidebar.tsx +++ b/frontend/portal/src/components/Sidebar.tsx @@ -8,8 +8,8 @@ import { useLink } from "@portal/contexts/LinkContext"; import { useAsync } from "@portal/hooks/useAsync"; import { fetchHomeKpis, type KpiEntry } from "@portal/api/home"; import { EDITOR_URL } from "@portal/auth/editorUrl"; -import markLight from "@shared/assets/stirling-mark-light.svg"; -import markDark from "@shared/assets/stirling-mark-dark.svg"; +import markLight from "@shared/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg"; +import markDark from "@shared/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg"; import { HomeIcon, UsersIcon, diff --git a/frontend/shared/assets/Brand.stories.tsx b/frontend/shared/assets/Brand.stories.tsx new file mode 100644 index 0000000000..87cf9f1257 --- /dev/null +++ b/frontend/shared/assets/Brand.stories.tsx @@ -0,0 +1,106 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +// Visual catalogue of the shared brand assets (shared/assets/brand) — the single +// source of truth for the Stirling logos used across the apps. +import modernMarkDark from "@shared/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg"; +import modernMarkLight from "@shared/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg"; +import modernBlack from "@shared/assets/brand/modern-logo/StirlingPDFLogoBlackText.svg"; +import modernWhite from "@shared/assets/brand/modern-logo/StirlingPDFLogoWhiteText.svg"; +import modernGrey from "@shared/assets/brand/modern-logo/StirlingPDFLogoGreyText.svg"; +import classicMarkDark from "@shared/assets/brand/classic-logo/StirlingPDFLogoNoTextDark.svg"; +import classicMarkLight from "@shared/assets/brand/classic-logo/StirlingPDFLogoNoTextLight.svg"; +import classicBlack from "@shared/assets/brand/classic-logo/StirlingPDFLogoBlackText.svg"; +import classicWhite from "@shared/assets/brand/classic-logo/StirlingPDFLogoWhiteText.svg"; +import classicGrey from "@shared/assets/brand/classic-logo/StirlingPDFLogoGreyText.svg"; + +type Asset = { label: string; src: string; onDark?: boolean }; +type VariantSet = { variant: string; mark: Asset[]; wordmark: Asset[] }; + +const SETS: VariantSet[] = [ + { + variant: "modern", + mark: [ + { label: "NoTextDark", src: modernMarkDark }, + { label: "NoTextLight", src: modernMarkLight, onDark: true }, + ], + wordmark: [ + { label: "BlackText", src: modernBlack }, + { label: "GreyText", src: modernGrey }, + { label: "WhiteText", src: modernWhite, onDark: true }, + ], + }, + { + variant: "classic", + mark: [ + { label: "NoTextDark", src: classicMarkDark }, + { label: "NoTextLight", src: classicMarkLight, onDark: true }, + ], + wordmark: [ + { label: "BlackText", src: classicBlack }, + { label: "GreyText", src: classicGrey }, + { label: "WhiteText", src: classicWhite, onDark: true }, + ], + }, +]; + +function Swatch({ label, src, onDark, h }: Asset & { h: number }) { + return ( +
+
+ {label} +
+
+ {label} +
+
+ ); +} + +function Row({ title, items }: { title: string; items: Asset[] }) { + return ( +
+

{title}

+
+ {items.map((a) => ( + + ))} +
+
+ ); +} + +const meta: Meta = { + title: "Brand/Logos", + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Every brand mark + wordmark, per variant, on the background each is built for. */ +export const Logos: Story = { + render: () => ( +
+ {SETS.map((set) => ( +
+

+ {set.variant} +

+ + +
+ ))} +
+ ), +}; diff --git a/frontend/editor/public/classic-logo/Firstpage.png b/frontend/shared/assets/brand/classic-logo/Firstpage.png similarity index 100% rename from frontend/editor/public/classic-logo/Firstpage.png rename to frontend/shared/assets/brand/classic-logo/Firstpage.png diff --git a/frontend/editor/public/classic-logo/StirlingPDFLogoBlackText.svg b/frontend/shared/assets/brand/classic-logo/StirlingPDFLogoBlackText.svg similarity index 100% rename from frontend/editor/public/classic-logo/StirlingPDFLogoBlackText.svg rename to frontend/shared/assets/brand/classic-logo/StirlingPDFLogoBlackText.svg diff --git a/frontend/editor/public/classic-logo/StirlingPDFLogoGreyText.svg b/frontend/shared/assets/brand/classic-logo/StirlingPDFLogoGreyText.svg similarity index 100% rename from frontend/editor/public/classic-logo/StirlingPDFLogoGreyText.svg rename to frontend/shared/assets/brand/classic-logo/StirlingPDFLogoGreyText.svg diff --git a/frontend/editor/public/classic-logo/StirlingPDFLogoNoTextDark.svg b/frontend/shared/assets/brand/classic-logo/StirlingPDFLogoNoTextDark.svg similarity index 100% rename from frontend/editor/public/classic-logo/StirlingPDFLogoNoTextDark.svg rename to frontend/shared/assets/brand/classic-logo/StirlingPDFLogoNoTextDark.svg diff --git a/frontend/editor/public/classic-logo/StirlingPDFLogoNoTextLight.svg b/frontend/shared/assets/brand/classic-logo/StirlingPDFLogoNoTextLight.svg similarity index 100% rename from frontend/editor/public/classic-logo/StirlingPDFLogoNoTextLight.svg rename to frontend/shared/assets/brand/classic-logo/StirlingPDFLogoNoTextLight.svg diff --git a/frontend/editor/public/classic-logo/StirlingPDFLogoWhiteText.svg b/frontend/shared/assets/brand/classic-logo/StirlingPDFLogoWhiteText.svg similarity index 100% rename from frontend/editor/public/classic-logo/StirlingPDFLogoWhiteText.svg rename to frontend/shared/assets/brand/classic-logo/StirlingPDFLogoWhiteText.svg diff --git a/frontend/editor/public/classic-logo/favicon.ico b/frontend/shared/assets/brand/classic-logo/favicon.ico similarity index 100% rename from frontend/editor/public/classic-logo/favicon.ico rename to frontend/shared/assets/brand/classic-logo/favicon.ico diff --git a/frontend/editor/public/classic-logo/logo-tooltip.svg b/frontend/shared/assets/brand/classic-logo/logo-tooltip.svg similarity index 100% rename from frontend/editor/public/classic-logo/logo-tooltip.svg rename to frontend/shared/assets/brand/classic-logo/logo-tooltip.svg diff --git a/frontend/editor/public/classic-logo/logo192.png b/frontend/shared/assets/brand/classic-logo/logo192.png similarity index 100% rename from frontend/editor/public/classic-logo/logo192.png rename to frontend/shared/assets/brand/classic-logo/logo192.png diff --git a/frontend/editor/public/classic-logo/logo512.png b/frontend/shared/assets/brand/classic-logo/logo512.png similarity index 100% rename from frontend/editor/public/classic-logo/logo512.png rename to frontend/shared/assets/brand/classic-logo/logo512.png diff --git a/frontend/editor/public/modern-logo/Firstpage.png b/frontend/shared/assets/brand/modern-logo/Firstpage.png similarity index 100% rename from frontend/editor/public/modern-logo/Firstpage.png rename to frontend/shared/assets/brand/modern-logo/Firstpage.png diff --git a/frontend/editor/public/modern-logo/LoginDarkModeHeader.svg b/frontend/shared/assets/brand/modern-logo/LoginDarkModeHeader.svg similarity index 100% rename from frontend/editor/public/modern-logo/LoginDarkModeHeader.svg rename to frontend/shared/assets/brand/modern-logo/LoginDarkModeHeader.svg diff --git a/frontend/shared/assets/login/LoginLightModeHeader.svg b/frontend/shared/assets/brand/modern-logo/LoginLightModeHeader.svg similarity index 100% rename from frontend/shared/assets/login/LoginLightModeHeader.svg rename to frontend/shared/assets/brand/modern-logo/LoginLightModeHeader.svg diff --git a/frontend/editor/public/modern-logo/StirlingPDFLogoBlackText.svg b/frontend/shared/assets/brand/modern-logo/StirlingPDFLogoBlackText.svg similarity index 100% rename from frontend/editor/public/modern-logo/StirlingPDFLogoBlackText.svg rename to frontend/shared/assets/brand/modern-logo/StirlingPDFLogoBlackText.svg diff --git a/frontend/editor/public/modern-logo/StirlingPDFLogoGreyText.svg b/frontend/shared/assets/brand/modern-logo/StirlingPDFLogoGreyText.svg similarity index 100% rename from frontend/editor/public/modern-logo/StirlingPDFLogoGreyText.svg rename to frontend/shared/assets/brand/modern-logo/StirlingPDFLogoGreyText.svg diff --git a/frontend/editor/public/modern-logo/StirlingPDFLogoNoTextDark.svg b/frontend/shared/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg similarity index 100% rename from frontend/editor/public/modern-logo/StirlingPDFLogoNoTextDark.svg rename to frontend/shared/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg diff --git a/frontend/editor/public/modern-logo/StirlingPDFLogoNoTextLight.svg b/frontend/shared/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg similarity index 100% rename from frontend/editor/public/modern-logo/StirlingPDFLogoNoTextLight.svg rename to frontend/shared/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg diff --git a/frontend/editor/public/modern-logo/StirlingPDFLogoWhiteText.svg b/frontend/shared/assets/brand/modern-logo/StirlingPDFLogoWhiteText.svg similarity index 100% rename from frontend/editor/public/modern-logo/StirlingPDFLogoWhiteText.svg rename to frontend/shared/assets/brand/modern-logo/StirlingPDFLogoWhiteText.svg diff --git a/frontend/editor/public/modern-logo/favicon.ico b/frontend/shared/assets/brand/modern-logo/favicon.ico similarity index 100% rename from frontend/editor/public/modern-logo/favicon.ico rename to frontend/shared/assets/brand/modern-logo/favicon.ico diff --git a/frontend/editor/public/modern-logo/logo-tooltip.svg b/frontend/shared/assets/brand/modern-logo/logo-tooltip.svg similarity index 100% rename from frontend/editor/public/modern-logo/logo-tooltip.svg rename to frontend/shared/assets/brand/modern-logo/logo-tooltip.svg diff --git a/frontend/editor/public/modern-logo/logo192.png b/frontend/shared/assets/brand/modern-logo/logo192.png similarity index 100% rename from frontend/editor/public/modern-logo/logo192.png rename to frontend/shared/assets/brand/modern-logo/logo192.png diff --git a/frontend/editor/public/modern-logo/logo512.png b/frontend/shared/assets/brand/modern-logo/logo512.png similarity index 100% rename from frontend/editor/public/modern-logo/logo512.png rename to frontend/shared/assets/brand/modern-logo/logo512.png diff --git a/frontend/shared/assets/stirling-mark-dark.svg b/frontend/shared/assets/stirling-mark-dark.svg deleted file mode 100644 index a6f82dd6f6..0000000000 --- a/frontend/shared/assets/stirling-mark-dark.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/shared/assets/stirling-mark-light.svg b/frontend/shared/assets/stirling-mark-light.svg deleted file mode 100644 index 62a5b38387..0000000000 --- a/frontend/shared/assets/stirling-mark-light.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/shared/assets/stirling-pdf-logo-dark.svg b/frontend/shared/assets/stirling-pdf-logo-dark.svg deleted file mode 100644 index ade6937870..0000000000 --- a/frontend/shared/assets/stirling-pdf-logo-dark.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/shared/assets/stirling-pdf-logo-light.svg b/frontend/shared/assets/stirling-pdf-logo-light.svg deleted file mode 100644 index a4a1a1f87e..0000000000 --- a/frontend/shared/assets/stirling-pdf-logo-light.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - From e44da5c410fc0d5a8a645be4045082bdddc670fc Mon Sep 17 00:00:00 2001 From: James Brunton Date: Tue, 30 Jun 2026 15:07:12 +0100 Subject: [PATCH 2/3] Fix missing refresh token on desktop (#6838) # Description of Changes Fix #6801, along with fixing policies on desktop, which would attempt to download policy outputs from the local backend instead of the server, where they actually live. I've changed the policies logic to maintain the same backend for the file retrieval as it used for the policy running, so when we support running policies locally, it should still work correctly. --- .../public/locales/en-US/translation.toml | 11 +++ .../editor/src-tauri/src/commands/auth.rs | 4 + .../src/desktop/services/authService.ts | 14 +++- .../desktop/services/policyOutputBaseUrl.ts | 12 +++ .../policies/policyRunStore.test.ts | 1 + .../components/policies/policyRunStore.ts | 8 +- .../policies/usePolicyAutoRun.import.test.tsx | 2 + .../policies/usePolicyAutoRun.retry.test.tsx | 2 + .../components/policies/usePolicyAutoRun.ts | 75 ++++++++++++++++--- .../hooks/usePolicyFileBadges.test.ts | 1 + .../src/proprietary/services/policyApi.ts | 23 +++++- .../src/proprietary/services/policyExport.ts | 9 ++- .../services/policyLiveData.test.ts | 9 ++- .../proprietary/services/policyLiveData.ts | 18 +++-- .../services/policyOutputBaseUrl.ts | 13 ++++ .../proprietary/services/policyPipeline.ts | 6 ++ 16 files changed, 177 insertions(+), 31 deletions(-) create mode 100644 frontend/editor/src/desktop/services/policyOutputBaseUrl.ts create mode 100644 frontend/editor/src/proprietary/services/policyOutputBaseUrl.ts diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 41a2f220e5..670c315589 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -5886,6 +5886,17 @@ successMessage = "Your license has been successfully activated. You can now clos deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected." deleteConfirmTitle = "Delete {{label}} policy?" +[policies.activity] +enforced = "enforced" +enforcing = "Enforcing..." +failed = "Enforcement failed" +outputsUnavailable = "Policy outputs are no longer available to download." +partialOutputsUnavailable = "Some policy outputs are no longer available to download." +retrying = "Busy, retrying..." +runNotFound = "The enforcement run could no longer be found." +step = "step {{current}}/{{total}}" +timedOut = "Enforcement timed out before the run could finish." + [policies.catalog] compliance = "Compliance" ingestion = "Ingestion" diff --git a/frontend/editor/src-tauri/src/commands/auth.rs b/frontend/editor/src-tauri/src/commands/auth.rs index 28d2ddd376..b906bbbf7d 100644 --- a/frontend/editor/src-tauri/src/commands/auth.rs +++ b/frontend/editor/src-tauri/src/commands/auth.rs @@ -400,6 +400,7 @@ struct SupabaseUser { #[derive(Debug, Deserialize)] struct SupabaseLoginResponse { access_token: String, + refresh_token: Option, user: SupabaseUser, } @@ -408,6 +409,7 @@ pub struct LoginResponse { pub token: String, pub username: String, pub email: Option, + pub refresh_token: Option, } /// Login command - makes HTTP request from Rust to bypass CORS @@ -513,6 +515,7 @@ pub async fn login( token: login_response.access_token, username, email, + refresh_token: login_response.refresh_token, }) } else { // Spring Boot authentication flow @@ -615,6 +618,7 @@ pub async fn login( token: login_response.session.access_token, username: login_response.user.username, email: login_response.user.email, + refresh_token: None, }) } } diff --git a/frontend/editor/src/desktop/services/authService.ts b/frontend/editor/src/desktop/services/authService.ts index 6f5d3214f3..63181ef1dd 100644 --- a/frontend/editor/src/desktop/services/authService.ts +++ b/frontend/editor/src/desktop/services/authService.ts @@ -30,6 +30,7 @@ interface LoginResponse { token: string; username: string; email: string | null; + refresh_token: string | null; } interface OAuthCallbackResult { @@ -347,11 +348,18 @@ export class AuthService { saasServerUrl: STIRLING_SAAS_URL, }); - const { token, username: returnedUsername, email } = response; + const { + token, + username: returnedUsername, + email, + refresh_token: refreshToken, + } = response; - // Save token to all storage locations + // Save token to all storage locations. Supabase (SaaS) logins include a + // refresh token so the short-lived access token can be renewed; self-hosted + // logins return null here and refresh via the current access token instead. try { - await this.saveTokenEverywhere(token); + await this.saveTokenEverywhere(token, refreshToken); } catch (error) { console.error("[Desktop AuthService] Failed to save token:", error); throw new Error("Failed to save authentication token", { diff --git a/frontend/editor/src/desktop/services/policyOutputBaseUrl.ts b/frontend/editor/src/desktop/services/policyOutputBaseUrl.ts new file mode 100644 index 0000000000..82b452aa0c --- /dev/null +++ b/frontend/editor/src/desktop/services/policyOutputBaseUrl.ts @@ -0,0 +1,12 @@ +import { STIRLING_SAAS_BACKEND_API_URL } from "@app/constants/connection"; +import type { PolicyExecutionTarget } from "@app/services/policyPipeline"; + +/** + * Desktop: a policy run's outputs live on the backend that executed it. + */ +export function getPolicyOutputBaseUrl(target: PolicyExecutionTarget): string { + if (target === "saas") { + return (STIRLING_SAAS_BACKEND_API_URL ?? "").replace(/\/$/, ""); + } + return ""; +} diff --git a/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts b/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts index 922e64650f..2034866b87 100644 --- a/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts +++ b/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts @@ -18,6 +18,7 @@ function rec(over: Partial): PolicyRunRecord { fileId: "f1", fileName: "f.pdf", fileSize: 10, + target: "saas", status: "PENDING", outputs: [], error: null, diff --git a/frontend/editor/src/proprietary/components/policies/policyRunStore.ts b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts index 295c45d737..45a8066bf0 100644 --- a/frontend/editor/src/proprietary/components/policies/policyRunStore.ts +++ b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts @@ -10,7 +10,10 @@ */ import { useSyncExternalStore } from "react"; -import type { PolicyRunStatus } from "@app/services/policyPipeline"; +import type { + PolicyExecutionTarget, + PolicyRunStatus, +} from "@app/services/policyPipeline"; export interface PolicyRunRecord { runId: string; @@ -18,6 +21,7 @@ export interface PolicyRunRecord { fileId: string; fileName: string; fileSize: number; + target: PolicyExecutionTarget; status: PolicyRunStatus; /** Pipeline progress reported by the run-status endpoint: the 1-based step * currently running, and the total step count. Drive the "step X/Y" label @@ -72,6 +76,8 @@ function read(): RunState { importedFileIds: Array.isArray(r.importedFileIds) ? r.importedFileIds : [], + // Records predating per-run targets all executed on SaaS. + target: r.target === "local" ? "local" : "saas", })) : [], dispatched: Array.isArray(parsed.dispatched) ? parsed.dispatched : [], diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx index 36763a3e37..6090557c70 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx @@ -48,6 +48,7 @@ vi.mock("@app/services/policyApi", () => ({ getPolicyRun: vi.fn(), listPolicyRuns: mocks.listPolicyRuns, downloadPolicyOutput: mocks.downloadPolicyOutput, + resolvePolicyRunTarget: () => "saas", })); vi.mock("@app/services/fileStorage", () => ({ fileStorage: { @@ -75,6 +76,7 @@ function recordCompletedRun() { fileId: "file-1", fileName: "doc.pdf", fileSize: 1234, + target: "saas", status: "COMPLETED", outputs: [{ fileId: "out-file-1", fileName: "doc.pdf" }], error: null, diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx index a0891a28f5..7acc1ae2bb 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx @@ -25,6 +25,7 @@ vi.mock("@app/services/policyApi", () => ({ runStoredPolicy: vi.fn(), getPolicyRun: vi.fn(), downloadPolicyOutput: vi.fn(), + resolvePolicyRunTarget: () => "saas", })); vi.mock("@app/services/fileStorage", () => ({ fileStorage: { getStirlingFile: vi.fn(), getStirlingFileStub: vi.fn() }, @@ -80,6 +81,7 @@ describe("auto-run queue-rejection retry", () => { fileId: "file-1", fileName: "doc.pdf", fileSize: 1234, + target: "saas", status: "RUNNING", outputs: [], error: null, diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index 8440d53b5b..19f88c9304 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -20,11 +20,13 @@ import { import { fileStorage } from "@app/services/fileStorage"; import { useIndexedDB } from "@app/contexts/IndexedDBContext"; import { POLICIES_ENABLED } from "@app/constants/featureFlags"; +import i18n from "@app/i18n"; import { runStoredPolicy, getPolicyRun, listPolicyRuns, downloadPolicyOutput, + resolvePolicyRunTarget, } from "@app/services/policyApi"; import type { PolicyRunStatus, @@ -82,9 +84,9 @@ const QUEUE_RETRY_BASE_MS = 4000; * to an instance that hasn't seen it) then fail, rather than polling forever. */ const MAX_NOT_FOUND = 3; -/** A 404 from the run-status endpoint, across the web (axios) and desktop - * (tauri http client → {@code code: "ERR_NOT_FOUND"}) builds. */ -function isRunNotFound(err: unknown): boolean { +/** A 404 (run status gone, or output file gone), across the web (axios) and + * desktop (tauri http client → {@code code: "ERR_NOT_FOUND"}) builds. */ +function isNotFoundError(err: unknown): boolean { const e = err as | { code?: string; status?: number; response?: { status?: number } } | null @@ -354,6 +356,9 @@ async function reconcileServerRuns( fileId: "", fileName: view.outputs[0]?.fileName ?? "", fileSize: 0, + // Rediscovered from the SaaS run registry (listPolicyRuns), so its outputs + // live on the cloud backend. + target: "saas", status: view.status, outputs: view.outputs, error: view.error, @@ -403,9 +408,9 @@ async function importOutputs( const targetName = ctx.outputName ? undefined // use the run's per-output (renamed) name below : run.fileName; - const results = await Promise.allSettled( + const settled = await Promise.allSettled( pending.map(async (out) => { - const blob = await downloadPolicyOutput(out.fileId); + const blob = await downloadPolicyOutput(out.fileId, run.target); return { fileId: out.fileId, file: new File([blob], targetName ?? out.fileName ?? run.fileName, { @@ -414,13 +419,33 @@ async function importOutputs( }; }), ); - const fetched = results + const fetched = settled .filter( (r): r is PromiseFulfilledResult<{ fileId: string; file: File }> => r.status === "fulfilled", ) .map((r) => r.value); - if (fetched.length === 0) return; // all failed — retry the lot on a later tick + // A 404 means the backend no longer has that output (past its retention + // window); retrying it can never succeed, so don't loop on it forever. Any + // other rejection is transient and worth retrying on a later tick. + const rejections = settled + .filter((r): r is PromiseRejectedResult => r.status === "rejected") + .map((r) => r.reason); + const allFailuresPermanent = + rejections.length > 0 && rejections.every(isNotFoundError); + + if (fetched.length === 0) { + if (allFailuresPermanent) { + failRun( + run.runId, + i18n.t( + "policies.activity.outputsUnavailable", + "Policy outputs are no longer available to download.", + ), + ); + } + return; // transient/mixed: retry the lot later; permanent: already failed. + } // Deliver, then mark exactly those imported. If delivery throws we don't mark // them, so they retry (without having been added). @@ -472,12 +497,26 @@ async function importOutputs( deliveredIds = added.map((f) => f.fileId as string); } const importedFileIds = [...done, ...fetched.map((f) => f.fileId)]; + const imported = run.outputs.every((out) => + importedFileIds.includes(out.fileId), + ); updateRun(run.runId, { importedFileIds, // Accumulate across partial-import retries rather than overwriting. outputFileIds: [...(run.outputFileIds ?? []), ...deliveredIds], - imported: run.outputs.every((out) => importedFileIds.includes(out.fileId)), + imported, }); + // Some outputs landed but the rest are permanently gone (404): finalize so the + // run stops re-fetching the missing ones on every tick. + if (!imported && allFailuresPermanent) { + failRun( + run.runId, + i18n.t( + "policies.activity.partialOutputsUnavailable", + "Some policy outputs are no longer available to download.", + ), + ); + } } /** @@ -515,6 +554,7 @@ export async function runPolicyOnFile( return; } try { + const target = resolvePolicyRunTarget(); const runId = await runStoredPolicy(backendId, [file]); // recordRunStart marks this (policy, file) dispatched as it records the run. recordRunStart({ @@ -523,6 +563,7 @@ export async function runPolicyOnFile( fileId, fileName, fileSize: file.size, + target, status: "PENDING", outputs: [], error: null, @@ -562,9 +603,15 @@ export async function poll( // The server lost the run's (in-memory) state — a restart, or a poll that // hopped to an instance without it. Tolerate a brief blip, then fail so // the file stops enforcing forever; the user can retry. - if (isRunNotFound(err)) { + if (isNotFoundError(err)) { if (++notFoundStreak >= MAX_NOT_FOUND) { - failRun(runId, "The enforcement run could no longer be found."); + failRun( + runId, + i18n.t( + "policies.activity.runNotFound", + "The enforcement run could no longer be found.", + ), + ); return; } } else { @@ -591,5 +638,11 @@ export async function poll( } // Budget exhausted without a terminal status — stop here and fail it, so the // file doesn't enforce forever and reloads don't re-poll it. - failRun(runId, "Enforcement timed out — the run didn't finish in time."); + failRun( + runId, + i18n.t( + "policies.activity.timedOut", + "Enforcement timed out before the run could finish.", + ), + ); } diff --git a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts index 30c5a83952..db69bb77af 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts @@ -15,6 +15,7 @@ function run(overrides: Partial): PolicyRunRecord { fileId: "in", fileName: "in.pdf", fileSize: 1, + target: "saas", status: "COMPLETED", outputs: [], outputFileIds: ["out"], diff --git a/frontend/editor/src/proprietary/services/policyApi.ts b/frontend/editor/src/proprietary/services/policyApi.ts index 1936cf0894..b3f1fd999f 100644 --- a/frontend/editor/src/proprietary/services/policyApi.ts +++ b/frontend/editor/src/proprietary/services/policyApi.ts @@ -6,9 +6,11 @@ */ import apiClient from "@app/services/apiClient"; +import { getPolicyOutputBaseUrl } from "@app/services/policyOutputBaseUrl"; import type { BackendPipelineDefinition, BackendPolicy, + PolicyExecutionTarget, PolicyRunView, } from "@app/services/policyPipeline"; @@ -88,10 +90,25 @@ export async function runPolicyPipeline( return res.data.jobId; } -/** Download a run's output file by id (via the shared general-files endpoint). */ -export async function downloadPolicyOutput(fileId: string): Promise { +/** + * Where a policy run executes, and thus the backend that holds its outputs. + */ +export function resolvePolicyRunTarget(): PolicyExecutionTarget { + return "saas"; +} + +/** + * Download a run's output file by id (via the shared general-files endpoint). + * `target` is where the run executed: it selects the backend the file is fetched + * from, so a SaaS run's output isn't looked for on the bundled local backend. + */ +export async function downloadPolicyOutput( + fileId: string, + target: PolicyExecutionTarget, +): Promise { + const base = getPolicyOutputBaseUrl(target); const res = await apiClient.get( - `/api/v1/general/files/${encodeURIComponent(fileId)}`, + `${base}/api/v1/general/files/${encodeURIComponent(fileId)}`, { responseType: "blob" }, ); return res.data; diff --git a/frontend/editor/src/proprietary/services/policyExport.ts b/frontend/editor/src/proprietary/services/policyExport.ts index f4dce74873..92b14712dd 100644 --- a/frontend/editor/src/proprietary/services/policyExport.ts +++ b/frontend/editor/src/proprietary/services/policyExport.ts @@ -16,7 +16,9 @@ import { runStoredPolicy, getPolicyRun, downloadPolicyOutput, + resolvePolicyRunTarget, } from "@app/services/policyApi"; +import type { PolicyExecutionTarget } from "@app/services/policyPipeline"; import { recordRunStart, isDispatched, @@ -52,6 +54,7 @@ interface ExportPolicy { interface PolicyRunResult { file: File; runId: string; + target: PolicyExecutionTarget; outputs: { fileId: string; fileName: string }[]; } @@ -84,6 +87,7 @@ async function runToCompletion( backendId: string, file: File, ): Promise { + const target = resolvePolicyRunTarget(); const runId = await runStoredPolicy(backendId, [file]); for (let i = 0; i < MAX_POLLS; i++) { await delay(POLL_MS); @@ -96,12 +100,12 @@ async function runToCompletion( if (view.status === "COMPLETED") { const out = view.outputs?.[0]; if (!out) throw new Error("policy produced no output"); - const blob = await downloadPolicyOutput(out.fileId); + const blob = await downloadPolicyOutput(out.fileId, target); // Keep the export's filename; only the bytes are the enforced result. const enforced = new File([blob], file.name, { type: blob.type || file.type || "application/pdf", }); - return { file: enforced, runId, outputs: view.outputs ?? [] }; + return { file: enforced, runId, target, outputs: view.outputs ?? [] }; } if (view.status === "FAILED" || view.status === "CANCELLED") { throw new Error(view.error || `policy run ${view.status.toLowerCase()}`); @@ -219,6 +223,7 @@ export async function enforceExportPolicies( fileId, fileName: file.name, fileSize: file.size, + target: versionRun!.target, status: "COMPLETED", outputs: versionRun.outputs, error: null, diff --git a/frontend/editor/src/proprietary/services/policyLiveData.test.ts b/frontend/editor/src/proprietary/services/policyLiveData.test.ts index 38b96c845e..c3016f071b 100644 --- a/frontend/editor/src/proprietary/services/policyLiveData.test.ts +++ b/frontend/editor/src/proprietary/services/policyLiveData.test.ts @@ -14,6 +14,7 @@ function run(over: Partial): PolicyRunRecord { fileId: "f1", fileName: "f.pdf", fileSize: 0, + target: "saas", status: "COMPLETED", outputs: [], error: null, @@ -47,7 +48,7 @@ describe("runsToActivity", () => { expect(activity[0]).toMatchObject({ doc: "fresh.pdf", status: "processing", - action: "Enforcing…", + action: "Enforcing...", }); expect(activity[1]).toMatchObject({ doc: "contract.pdf", @@ -66,9 +67,9 @@ describe("runsToActivity", () => { run({ runId: "a", status: "RUNNING", currentStep: 1, stepCount: 2 }), run({ runId: "b", status: "RUNNING" }), ]); - expect(withStep.action).toBe("Enforcing… · step 1/2"); + expect(withStep.action).toBe("Enforcing... · step 1/2"); // Before the first status report (no step yet) it stays the plain label. - expect(noStep.action).toBe("Enforcing…"); + expect(noStep.action).toBe("Enforcing..."); }); it("shows a queue-rejected run awaiting retry as busy, not a failure", () => { @@ -81,7 +82,7 @@ describe("runsToActivity", () => { }), ]); expect(item.status).toBe("processing"); - expect(item.action).toBe("Busy — retrying…"); + expect(item.action).toBe("Busy, retrying..."); }); it("shows a queue rejection that has exhausted its retries as a failure", () => { diff --git a/frontend/editor/src/proprietary/services/policyLiveData.ts b/frontend/editor/src/proprietary/services/policyLiveData.ts index 67d53f9c5e..56401f4d19 100644 --- a/frontend/editor/src/proprietary/services/policyLiveData.ts +++ b/frontend/editor/src/proprietary/services/policyLiveData.ts @@ -5,6 +5,7 @@ * policy's actual enforcement history — not a cosmetic file listing. */ +import i18n from "@app/i18n"; import type { PolicyActivityItem, PolicyStats } from "@app/types/policies"; import type { PolicyRunRecord } from "@app/components/policies/policyRunStore"; @@ -58,17 +59,20 @@ function activityStatus(run: PolicyRunRecord): PolicyActivityItem["status"] { function activityAction(run: PolicyRunRecord): string { switch (activityStatus(run)) { case "enforced": - return `${formatBytes(run.fileSize)} • enforced`; + return `${formatBytes(run.fileSize)} • ${i18n.t("policies.activity.enforced", "enforced")}`; case "flagged": - return run.error ?? "Enforcement failed"; + return ( + run.error ?? i18n.t("policies.activity.failed", "Enforcement failed") + ); default: { - if (run.retrying) return "Busy — retrying…"; - // Show pipeline progress while running, once the status endpoint reports - // it — turns a static "Enforcing…" into visible movement on slow steps. + if (run.retrying) + return i18n.t("policies.activity.retrying", "Busy, retrying..."); + const enforcing = i18n.t("policies.activity.enforcing", "Enforcing..."); + // Show pipeline progress while running, once the status endpoint reports it const { currentStep, stepCount } = run; return currentStep && stepCount - ? `Enforcing… · step ${currentStep}/${stepCount}` - : "Enforcing…"; + ? `${enforcing} · ${i18n.t("policies.activity.step", "step {{current}}/{{total}}", { current: currentStep, total: stepCount })}` + : enforcing; } } } diff --git a/frontend/editor/src/proprietary/services/policyOutputBaseUrl.ts b/frontend/editor/src/proprietary/services/policyOutputBaseUrl.ts new file mode 100644 index 0000000000..c5a127c0d5 --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyOutputBaseUrl.ts @@ -0,0 +1,13 @@ +import type { PolicyExecutionTarget } from "@app/services/policyPipeline"; + +/** + * Base URL for downloading a policy run's output file, given where the run + * executed. + * + * Web builds are served from their own backend, so a relative request resolves + * to the right place regardless of where the run ran, hence "" for every + * target. + */ +export function getPolicyOutputBaseUrl(_target: PolicyExecutionTarget): string { + return ""; +} diff --git a/frontend/editor/src/proprietary/services/policyPipeline.ts b/frontend/editor/src/proprietary/services/policyPipeline.ts index 21e304e4fb..b27bd09e4d 100644 --- a/frontend/editor/src/proprietary/services/policyPipeline.ts +++ b/frontend/editor/src/proprietary/services/policyPipeline.ts @@ -56,6 +56,12 @@ export interface BackendPolicy { output: BackendOutputSpec; } +/** + * Where a policy run executes, and therefore where its output files live and + * are downloaded from. + */ +export type PolicyExecutionTarget = "local" | "saas"; + /** Lifecycle states of a backend run (mirrors PolicyRunStatus). */ export type PolicyRunStatus = | "PENDING" From 276eb8f2a74e4f5d37389debff387aae97353ec4 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Tue, 30 Jun 2026 17:11:48 +0100 Subject: [PATCH 3/3] Add pipelines page to portal (#6818) # Description of Changes Connect pipelines page to the backend. Note that this is really half an implementation because the portal doesn't have access to the tools list and their settings, but I can't fix that without re-architecture work, which I'll do in another PR, then come back to finish this off in a new PR. image image --- .../policy/controller/PolicyController.java | 52 ++ .../policy/engine/PolicyRunner.java | 26 +- .../overview/PoliciesOverviewResponse.java | 6 + .../policy/overview/PolicyKpi.java | 4 + .../overview/PolicyOverviewService.java | 102 +++ .../policy/overview/PolicyView.java | 24 + .../policy/trigger/FolderWatchTrigger.java | 11 + .../policy/trigger/PolicyTrigger.java | 20 + .../policy/trigger/TriggerInfo.java | 18 + .../controller/PolicyControllerTest.java | 79 ++ .../overview/PolicyOverviewServiceTest.java | 186 +++++ .../public/locales/en-US/translation.toml | 191 ++--- frontend/portal/src/api/pipelines.ts | 189 ++++- .../DeployedPipelinesTable.stories.tsx | 32 - .../pipelines/DeployedPipelinesTable.tsx | 106 --- .../src/components/pipelines/KpiStrip.tsx | 39 + .../pipelines/PipelineCard.stories.tsx | 30 - .../src/components/pipelines/PipelineCard.tsx | 132 --- .../pipelines/PipelineComposer.stories.tsx | 25 - .../components/pipelines/PipelineComposer.tsx | 744 ++++++++++------- .../pipelines/PipelineDetail.stories.tsx | 31 - .../components/pipelines/PipelineDetail.tsx | 183 ----- .../pipelines/PipelineDetailCard.tsx | 213 +++++ .../PipelineListSkeleton.stories.tsx | 19 - .../pipelines/PipelineListSkeleton.tsx | 19 - .../components/pipelines/PipelinesTable.tsx | 122 +++ .../pipelines/PromotedPipelines.stories.tsx | 26 - .../pipelines/PromotedPipelines.tsx | 139 ---- .../portal/src/components/pipelines/format.ts | 26 - .../pipelines/pipelineOperations.ts | 50 ++ .../src/components/pipelines/stageAccent.ts | 35 - .../src/components/pipelines/storyFixtures.ts | 102 --- .../portal/src/mocks/handlers/pipelines.ts | 214 ++++- frontend/portal/src/mocks/pipelines.ts | 415 ---------- frontend/portal/src/views/Pipelines.css | 764 ++++++------------ frontend/portal/src/views/Pipelines.test.tsx | 245 ++++++ frontend/portal/src/views/Pipelines.tsx | 261 +++--- 37 files changed, 2444 insertions(+), 2436 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PoliciesOverviewResponse.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyKpi.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyView.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/TriggerInfo.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java delete mode 100644 frontend/portal/src/components/pipelines/DeployedPipelinesTable.stories.tsx delete mode 100644 frontend/portal/src/components/pipelines/DeployedPipelinesTable.tsx create mode 100644 frontend/portal/src/components/pipelines/KpiStrip.tsx delete mode 100644 frontend/portal/src/components/pipelines/PipelineCard.stories.tsx delete mode 100644 frontend/portal/src/components/pipelines/PipelineCard.tsx delete mode 100644 frontend/portal/src/components/pipelines/PipelineComposer.stories.tsx delete mode 100644 frontend/portal/src/components/pipelines/PipelineDetail.stories.tsx delete mode 100644 frontend/portal/src/components/pipelines/PipelineDetail.tsx create mode 100644 frontend/portal/src/components/pipelines/PipelineDetailCard.tsx delete mode 100644 frontend/portal/src/components/pipelines/PipelineListSkeleton.stories.tsx delete mode 100644 frontend/portal/src/components/pipelines/PipelineListSkeleton.tsx create mode 100644 frontend/portal/src/components/pipelines/PipelinesTable.tsx delete mode 100644 frontend/portal/src/components/pipelines/PromotedPipelines.stories.tsx delete mode 100644 frontend/portal/src/components/pipelines/PromotedPipelines.tsx delete mode 100644 frontend/portal/src/components/pipelines/format.ts create mode 100644 frontend/portal/src/components/pipelines/pipelineOperations.ts delete mode 100644 frontend/portal/src/components/pipelines/stageAccent.ts delete mode 100644 frontend/portal/src/components/pipelines/storyFixtures.ts delete mode 100644 frontend/portal/src/mocks/pipelines.ts create mode 100644 frontend/portal/src/views/Pipelines.test.tsx diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index 8a06582b58..64e6aa0523 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -2,6 +2,7 @@ package stirling.software.proprietary.policy.controller; import java.io.IOException; import java.util.ArrayList; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -52,11 +53,15 @@ import stirling.software.proprietary.policy.model.PolicyInputs; import stirling.software.proprietary.policy.model.PolicyRun; import stirling.software.proprietary.policy.model.PolicyRunStatus; import stirling.software.proprietary.policy.model.PolicyRunView; +import stirling.software.proprietary.policy.overview.PoliciesOverviewResponse; +import stirling.software.proprietary.policy.overview.PolicyOverviewService; import stirling.software.proprietary.policy.progress.PolicyProgressListener; import stirling.software.proprietary.policy.source.SourceAccessGuard; import stirling.software.proprietary.policy.source.SourceStore; import stirling.software.proprietary.policy.store.PolicyStore; +import stirling.software.proprietary.policy.trigger.PolicyTrigger; import stirling.software.proprietary.policy.trigger.PolicyTriggerManager; +import stirling.software.proprietary.policy.trigger.TriggerInfo; /** * Policy CRUD plus pipeline runs (stored or ad-hoc). Runs are async: returns a run id, poll {@code @@ -80,6 +85,8 @@ public class PolicyController { private final PolicyAccessGuard policyAccessGuard; private final PolicyManagementAuthority policyManagementAuthority; private final PolicyTriggerManager policyTriggerManager; + private final PolicyOverviewService policyOverviewService; + private final List policyTriggers; private final ApplicationProperties applicationProperties; private final TempFileManager tempFileManager; private final JobOwnershipService jobOwnershipService; @@ -287,6 +294,31 @@ public class PolicyController { return policyAccessGuard.visibleFrom(policyStore); } + @GetMapping("/overview") + @Operation( + summary = "Pipelines overview", + description = + "Returns the KPI strip plus one row per policy the caller's team owns, each with" + + " its referenced sources resolved to names, its pipeline steps, and a" + + " trigger/output summary. Backs the portal's all-pipelines surface.") + public PoliciesOverviewResponse overview() { + return policyOverviewService.overview(); + } + + @GetMapping("/triggers") + @Operation( + summary = "List available triggers", + description = + "Lists each trigger kind with whether it needs a source and which source types" + + " it supports, so the UI can offer triggers and pair them with the" + + " right sources.") + public List triggers() { + return policyTriggers.stream() + .map(TriggerInfo::of) + .sorted(Comparator.comparing(TriggerInfo::type)) + .toList(); + } + @GetMapping("/{policyId}") @Operation(summary = "Get a policy by id") public ResponseEntity getPolicy(@PathVariable String policyId) { @@ -337,6 +369,26 @@ public class PolicyController { return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null)); } + @PostMapping("/{policyId}/trigger") + @Operation( + summary = "Run a stored policy against its sources", + description = + "Pulls the policy's configured sources and runs the pipeline now, regardless of" + + " the enabled flag (which only gates automatic triggering). Returns" + + " the ids of the runs started; poll the run-status endpoint for each." + + " Empty when the sources yielded no work to do.") + public ResponseEntity> trigger(@PathVariable String policyId) { + Policy policy = + policyStore + .get(policyId) + .filter(policyAccessGuard::canAccess) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, "No policy: " + policyId)); + return ResponseEntity.accepted().body(policyRunner.run(policy)); + } + private static void requireRunnable(PipelineDefinition definition) { if (definition.steps().isEmpty()) { throw new ResponseStatusException( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java index 27f43c74da..819b6cae02 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java @@ -1,6 +1,7 @@ package stirling.software.proprietary.policy.engine; import java.io.IOException; +import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; @@ -41,14 +42,15 @@ public class PolicyRunner { * Trigger entry point. Pulls every referenced source; each yielded unit becomes its own run so * one failure does not affect the others. No sources means one run with no input (generator * pipeline). Missing or disabled sources are skipped so one broken reference does not stop the - * rest. + * rest. Returns the ids of the runs it started (empty when sources yielded no work), so a + * manual trigger can report back which runs to follow. */ - public void run(Policy policy) { + public List run(Policy policy) { List sourceIds = policy.sourceIds(); if (sourceIds.isEmpty()) { - startRun(policy, PolicyInputs.of(List.of()), unused -> {}); - return; + return List.of(startRun(policy, PolicyInputs.of(List.of()), unused -> {})); } + List runIds = new ArrayList<>(); for (String sourceId : sourceIds) { Source source = sourceStore.get(sourceId).orElse(null); if (source == null) { @@ -63,8 +65,9 @@ public class PolicyRunner { policy.id()); continue; } - pullAndRun(policy, source.toInputSpec()); + runIds.addAll(pullAndRun(policy, source.toInputSpec())); } + return runIds; } /** Run a stored policy on caller-supplied files (e.g. manual upload), bypassing its sources. */ @@ -79,14 +82,14 @@ public class PolicyRunner { return policyEngine.submit(definition, inputs, listener); } - private void pullAndRun(Policy policy, InputSpec spec) { + private List pullAndRun(Policy policy, InputSpec spec) { InputSource source = sourceFor(spec); if (source == null) { log.warn( "No input source for type '{}' (policy {}); skipping", spec.type(), policy.id()); - return; + return List.of(); } List work; try { @@ -97,19 +100,22 @@ public class PolicyRunner { spec.type(), policy.id(), e.getMessage()); - return; + return List.of(); } + List runIds = new ArrayList<>(); for (ResolvedInput unit : work) { - startRun(policy, unit.inputs(), unit.onComplete()); + runIds.add(startRun(policy, unit.inputs(), unit.onComplete())); } + return runIds; } - private void startRun(Policy policy, PolicyInputs inputs, Consumer onComplete) { + private String startRun(Policy policy, PolicyInputs inputs, Consumer onComplete) { log.info("Running policy {} ({})", policy.id(), policy.name()); PolicyRunHandle handle = policyEngine.runPolicy(policy, inputs, PolicyProgressListener.NOOP); handle.completion() .whenComplete((run, throwable) -> onComplete.accept(succeeded(run, throwable))); + return handle.runId(); } private static boolean succeeded(PolicyRun run, Throwable throwable) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PoliciesOverviewResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PoliciesOverviewResponse.java new file mode 100644 index 0000000000..9f94391f8b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PoliciesOverviewResponse.java @@ -0,0 +1,6 @@ +package stirling.software.proprietary.policy.overview; + +import java.util.List; + +/** The Pipelines overview payload: a KPI strip plus one row per policy. */ +public record PoliciesOverviewResponse(List kpis, List pipelines) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyKpi.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyKpi.java new file mode 100644 index 0000000000..a266cbe883 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyKpi.java @@ -0,0 +1,4 @@ +package stirling.software.proprietary.policy.overview; + +/** One headline figure in the Pipelines overview strip. */ +public record PolicyKpi(long value, String description) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java new file mode 100644 index 0000000000..5ba7856606 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java @@ -0,0 +1,102 @@ +package stirling.software.proprietary.policy.overview; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.policy.config.PolicyAccessGuard; +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineStep; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.TriggerConfig; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceAccessGuard; +import stirling.software.proprietary.policy.source.SourceStore; +import stirling.software.proprietary.policy.store.PolicyStore; + +/** + * Builds the Pipelines overview: every policy the caller's team owns, each annotated with its + * referenced sources (resolved to display names), its pipeline steps, and a trigger/output summary. + * Source names are resolved from the team's sources in memory rather than persisted on the policy, + * so the view always reflects the live source set. This is the "all pipelines" admin surface; the + * user-facing Policies page builds only a friendly subset of the same backend policies. + */ +@Service +@RequiredArgsConstructor +@ConditionalOnBooleanProperty(name = "policies.enabled") +public class PolicyOverviewService { + + private final PolicyStore policyStore; + private final SourceStore sourceStore; + private final PolicyAccessGuard policyAccessGuard; + private final SourceAccessGuard sourceAccessGuard; + + public PoliciesOverviewResponse overview() { + List policies = policyAccessGuard.visibleFrom(policyStore); + Map sourceNames = sourceNames(); + + List views = + policies.stream() + .map(policy -> toView(policy, sourceNames)) + .sorted( + Comparator.comparing( + PolicyView::name, String.CASE_INSENSITIVE_ORDER)) + .toList(); + + return new PoliciesOverviewResponse(buildKpis(policies), views); + } + + /** Display names for every source the caller's team can see, keyed by source id. */ + private Map sourceNames() { + Map names = new HashMap<>(); + for (Source source : sourceAccessGuard.visibleFrom(sourceStore)) { + names.put(source.id(), source.name()); + } + return names; + } + + private static PolicyView toView(Policy policy, Map sourceNames) { + List sources = + policy.sourceIds().stream() + // An unresolved id (source deleted, or not visible) falls back to the id so + // the row still renders rather than dropping the reference silently. + .map(id -> new PolicyView.SourceRef(id, sourceNames.getOrDefault(id, id))) + .toList(); + List steps = policy.steps().stream().map(PipelineStep::operation).toList(); + return new PolicyView( + policy.id(), + policy.name(), + policy.enabled(), + policy.enabled() ? "active" : "paused", + triggerSummary(policy.trigger()), + sources, + steps, + outputSummary(policy.output()), + policy.owner()); + } + + /** A null trigger is a manual-only policy; otherwise the trigger's type keys the summary. */ + private static String triggerSummary(TriggerConfig trigger) { + return trigger == null ? "manual" : trigger.type(); + } + + private static String outputSummary(OutputSpec output) { + return output == null ? "inline" : output.type(); + } + + private static List buildKpis(List policies) { + long total = policies.size(); + long active = policies.stream().filter(Policy::enabled).count(); + long paused = total - active; + return List.of( + new PolicyKpi(total, "pipelines"), + new PolicyKpi(active, "running automatically"), + new PolicyKpi(paused, "paused")); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyView.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyView.java new file mode 100644 index 0000000000..509379a66a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyView.java @@ -0,0 +1,24 @@ +package stirling.software.proprietary.policy.overview; + +import java.util.List; + +/** + * One row in the Pipelines overview: a stored policy shown for the admin portal, with its + * referenced sources resolved to names and its pipeline summarised. The portal's "all pipelines" + * surface lists every backend policy (the user-facing Policies page builds only a friendly subset + * of these). + */ +public record PolicyView( + String id, + String name, + boolean enabled, + String status, + String trigger, + List sources, + List steps, + String output, + String owner) { + + /** A source a policy pulls documents from, resolved to its display name. */ + public record SourceRef(String id, String name) {} +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java index 35c530fad0..79ba3e52e1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java @@ -27,6 +27,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.policy.config.FolderAccessGuard; import stirling.software.proprietary.policy.engine.PolicyRunner; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.model.InputSpec; @@ -74,6 +75,16 @@ public class FolderWatchTrigger implements PolicyTrigger { return TYPE; } + @Override + public boolean requiresSource() { + return true; + } + + @Override + public Set supportedSourceTypes() { + return Set.of(FolderAccessGuard.FOLDER_TYPE); + } + @Override public void validate(Policy policy) { if (watchDirsOf(policy).isEmpty()) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java index a97a9ac880..ade5357162 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java @@ -1,5 +1,7 @@ package stirling.software.proprietary.policy.trigger; +import java.util.Set; + import stirling.software.proprietary.policy.model.Policy; /** @@ -11,6 +13,24 @@ public interface PolicyTrigger { /** Matches {@code TriggerConfig.type()}. */ String type(); + /** + * Whether this trigger needs at least one compatible input source to function. A schedule fires + * on the clock regardless of sources, so it is false; folder-watch derives the directories it + * watches from the policy's sources, so it is true. Drives whether the UI offers the trigger. + */ + default boolean requiresSource() { + return false; + } + + /** + * The source {@code type()}s this trigger is compatible with (e.g. {@code "folder"}). Empty + * means source-agnostic (no constraint). Lets the UI offer a trigger only when a compatible + * source is selected, without hard-coding the relationship. + */ + default Set supportedSourceTypes() { + return Set.of(); + } + /** * Validate at save time so misconfiguration fails fast, not at fire time. Receives the whole * {@link Policy} so triggers that depend on the policy's sources (folder-watch) can check that. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/TriggerInfo.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/TriggerInfo.java new file mode 100644 index 0000000000..f8ad6e278b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/TriggerInfo.java @@ -0,0 +1,18 @@ +package stirling.software.proprietary.policy.trigger; + +import java.util.List; + +/** + * Describes an available trigger for the admin UI: its {@code type} (matching {@code + * TriggerConfig.type()}), whether it needs a compatible source, and which source types it works + * with. Lets the UI list supported triggers and pair them with sources without hard-coding the set. + */ +public record TriggerInfo(String type, boolean requiresSource, List supportedSourceTypes) { + + public static TriggerInfo of(PolicyTrigger trigger) { + return new TriggerInfo( + trigger.type(), + trigger.requiresSource(), + List.copyOf(trigger.supportedSourceTypes())); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java index 98ed101246..8a9a085653 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java @@ -57,12 +57,23 @@ class PolicyControllerTest { @Mock private PolicyAccessGuard policyAccessGuard; @Mock private PolicyManagementAuthority policyManagementAuthority; @Mock private PolicyTriggerManager policyTriggerManager; + + @Mock + private stirling.software.proprietary.policy.overview.PolicyOverviewService + policyOverviewService; + @Mock private TempFileManager tempFileManager; @Mock private JobOwnershipService jobOwnershipService; private ApplicationProperties applicationProperties; private PolicyController controller; + private final java.util.List + policyTriggers = + java.util.List.of( + trigger("schedule", false, java.util.Set.of()), + trigger("folder-watch", true, java.util.Set.of("folder"))); + @BeforeEach void setUp() { applicationProperties = new ApplicationProperties(); @@ -77,11 +88,33 @@ class PolicyControllerTest { policyAccessGuard, policyManagementAuthority, policyTriggerManager, + policyOverviewService, + policyTriggers, applicationProperties, tempFileManager, jobOwnershipService); } + private static stirling.software.proprietary.policy.trigger.PolicyTrigger trigger( + String type, boolean requiresSource, java.util.Set sourceTypes) { + return new stirling.software.proprietary.policy.trigger.PolicyTrigger() { + @Override + public String type() { + return type; + } + + @Override + public boolean requiresSource() { + return requiresSource; + } + + @Override + public java.util.Set supportedSourceTypes() { + return sourceTypes; + } + }; + } + private static PipelineDefinition definitionWithStep() { return new PipelineDefinition( "pipe", List.of(new PipelineStep("/api/v1/misc/compress-pdf", null)), null); @@ -431,4 +464,50 @@ class PolicyControllerTest { .isEqualTo(HttpStatus.NOT_FOUND)); } } + + @Nested + @DisplayName("triggers / trigger") + class Triggers { + + @Test + @DisplayName("lists triggers sorted, with source compatibility") + void listsTriggers() { + List infos = + controller.triggers(); + + assertThat(infos).extracting(t -> t.type()).containsExactly("folder-watch", "schedule"); + stirling.software.proprietary.policy.trigger.TriggerInfo folderWatch = infos.get(0); + assertThat(folderWatch.requiresSource()).isTrue(); + assertThat(folderWatch.supportedSourceTypes()).containsExactly("folder"); + assertThat(infos.get(1).requiresSource()).isFalse(); + assertThat(infos.get(1).supportedSourceTypes()).isEmpty(); + } + + @Test + @DisplayName("trigger runs an accessible policy against its sources and returns run ids") + void triggersRun() { + Policy p = policy("a", 1L); + when(policyStore.get("a")).thenReturn(Optional.of(p)); + when(policyAccessGuard.canAccess(p)).thenReturn(true); + when(policyRunner.run(p)).thenReturn(List.of("run-a", "run-b")); + + ResponseEntity> response = controller.trigger("a"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + assertThat(response.getBody()).containsExactly("run-a", "run-b"); + } + + @Test + @DisplayName("trigger is 404 when the policy is inaccessible") + void triggerNotFound() { + when(policyStore.get("z")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> controller.trigger("z")) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.NOT_FOUND)); + } + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java new file mode 100644 index 0000000000..221cbc4482 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java @@ -0,0 +1,186 @@ +package stirling.software.proprietary.policy.overview; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.UserServiceInterface; +import stirling.software.proprietary.policy.config.PolicyAccessGuard; +import stirling.software.proprietary.policy.config.PolicyManagementAuthority; +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineStep; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.TriggerConfig; +import stirling.software.proprietary.policy.source.InProcessSourceStore; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceAccessGuard; +import stirling.software.proprietary.policy.source.SourceStore; +import stirling.software.proprietary.policy.store.InProcessPolicyStore; +import stirling.software.proprietary.policy.store.PolicyStore; + +/** + * Tests for {@link PolicyOverviewService}: every policy appears once with its sources resolved to + * names, its steps and trigger/output summarised, and the KPI strip counting active vs paused. + * Login is disabled so the team guards pass everything through. + */ +class PolicyOverviewServiceTest { + + private final SourceStore sourceStore = new InProcessSourceStore(); + private final PolicyStore policyStore = new InProcessPolicyStore(); + private PolicyOverviewService service; + + @BeforeEach + void setUp() { + ApplicationProperties properties = new ApplicationProperties(); + properties.getSecurity().setEnableLogin(false); + UserServiceInterface userService = mock(UserServiceInterface.class); + PolicyManagementAuthority authority = mock(PolicyManagementAuthority.class); + SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority); + PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority); + service = new PolicyOverviewService(policyStore, sourceStore, policyGuard, sourceGuard); + } + + @Test + void eachPolicyAppearsWithResolvedSourcesStepsAndSummary() { + Source claims = source("Claims intake", "/claims"); + policyStore.save( + new Policy( + null, + "Redaction", + "owner", + true, + new TriggerConfig("schedule", Map.of()), + List.of(claims.id()), + List.of(new PipelineStep("/api/v1/security/auto-redact", Map.of())), + OutputSpec.inline())); + policyStore.save( + new Policy( + null, + "Archive (paused)", + "owner", + false, + null, + List.of(), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.inline())); + + PoliciesOverviewResponse response = service.overview(); + + assertEquals(2, response.pipelines().size()); + // Sorted by name, case-insensitive, so "Archive" leads "Redaction". + PolicyView archive = response.pipelines().get(0); + assertEquals("Archive (paused)", archive.name()); + assertEquals("paused", archive.status()); + assertEquals("manual", archive.trigger()); + + PolicyView redaction = find(response, "Redaction"); + assertEquals("active", redaction.status()); + assertEquals("schedule", redaction.trigger()); + assertEquals("inline", redaction.output()); + assertEquals(List.of("/api/v1/security/auto-redact"), redaction.steps()); + assertEquals(1, redaction.sources().size()); + assertEquals(claims.id(), redaction.sources().get(0).id()); + assertEquals("Claims intake", redaction.sources().get(0).name()); + + // KPI strip: total, active, paused. + assertEquals(List.of(2L, 1L, 1L), response.kpis().stream().map(PolicyKpi::value).toList()); + } + + @Test + void anUnresolvedSourceFallsBackToItsId() { + policyStore.save( + new Policy( + null, + "Orphan", + "owner", + true, + null, + List.of("src-missing"), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.inline())); + + PolicyView view = find(service.overview(), "Orphan"); + assertEquals(1, view.sources().size()); + assertEquals("src-missing", view.sources().get(0).id()); + assertEquals("src-missing", view.sources().get(0).name()); + } + + @Test + void overviewLoadsOnlyTheCallersTeam() { + ApplicationProperties properties = new ApplicationProperties(); + properties.getSecurity().setEnableLogin(true); + UserServiceInterface userService = mock(UserServiceInterface.class); + PolicyManagementAuthority authority = mock(PolicyManagementAuthority.class); + when(authority.currentUserTeamId()).thenReturn(1L); + SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority); + PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority); + PolicyOverviewService scoped = + new PolicyOverviewService(policyStore, sourceStore, policyGuard, sourceGuard); + + Source ours = teamSource("Ours", "/ours", 1L); + teamPolicy("Our policy", 1L, ours.id()); + teamPolicy("Their policy", 2L, ours.id()); + + PoliciesOverviewResponse response = scoped.overview(); + + assertEquals(1, response.pipelines().size()); + PolicyView view = response.pipelines().get(0); + assertEquals("Our policy", view.name()); + assertEquals("Ours", view.sources().get(0).name()); + assertEquals(List.of(1L, 1L, 0L), response.kpis().stream().map(PolicyKpi::value).toList()); + } + + @Test + void emptyStoreReportsZeroKpis() { + PoliciesOverviewResponse response = service.overview(); + assertTrue(response.pipelines().isEmpty()); + assertEquals(List.of(0L, 0L, 0L), response.kpis().stream().map(PolicyKpi::value).toList()); + } + + private Source source(String name, String directory) { + return sourceStore.save( + new Source( + null, name, "folder", Map.of("directory", directory), true, "owner", null)); + } + + private Source teamSource(String name, String directory, Long teamId) { + return sourceStore.save( + new Source( + null, + name, + "folder", + Map.of("directory", directory), + true, + "owner", + teamId)); + } + + private void teamPolicy(String name, Long teamId, String... sourceIds) { + policyStore.save( + new Policy( + null, + name, + "owner", + true, + null, + List.of(sourceIds), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.inline(), + teamId)); + } + + private static PolicyView find(PoliciesOverviewResponse response, String name) { + return response.pipelines().stream() + .filter(view -> view.name().equals(name)) + .findFirst() + .orElseThrow(); + } +} diff --git a/frontend/portal/public/locales/en-US/translation.toml b/frontend/portal/public/locales/en-US/translation.toml index c0b3ccae91..4e90ab71f1 100644 --- a/frontend/portal/public/locales/en-US/translation.toml +++ b/frontend/portal/public/locales/en-US/translation.toml @@ -206,141 +206,96 @@ description = "The component catalogue could not be loaded. Try again shortly." [pipelines] title = "Pipelines" -subtitle = "Document workflows composed from typed operations — deployed, versioned, and continuously validated against a golden set." +subtitle = "Every automated document pipeline on the backend: an ordered chain of operations over a set of sources, run on a trigger. Click a row for its steps and sources." + +[pipelines.actions] newPipeline = "New pipeline" +[pipelines.kpi] +total = "Pipelines" +active = "Active" +paused = "Paused" + [pipelines.status] -healthy = "Healthy" -degraded = "Degraded" +active = "Active" +paused = "Paused" -[pipelines.fleet] -healthy_one = "{{count}} healthy" -healthy_other = "{{count}} healthy" -degraded_one = "{{count}} degraded" -degraded_other = "{{count}} degraded" -deployed_one = "{{count}} deployed" -deployed_other = "{{count}} deployed" +[pipelines.trigger] +manual = "Manual" +schedule = "Scheduled" +folder-watch = "Folder watch" -[pipelines.evals] -title = "Shadow + comparative evals active" -body_one = "{{count}} pipeline running a shadow eval, {{comparativeCount}} in a comparative run. {{detail}}" -body_other = "{{count}} pipelines running a shadow eval, {{comparativeCount}} in a comparative run. {{detail}}" +[pipelines.output] +inline = "Return files" +folder = "Write to folder" [pipelines.empty] title = "No pipelines yet" -description = "Compose your first document workflow from the typed operation library — pick a source, chain the ops, and route the output." -action = "Build your first pipeline" - -[pipelines.reliability] -heading = "Golden-set reliability" -description = "Pass rate against each pipeline's golden set, judged against its own bound. Anything below bound shows amber or red." - -[pipelines.promoted] -heading = "Promoted from the Editor" -description = "Watch-folder flows built in the Editor and promoted into the portal. Promote one to a policy to apply its rules fleet-wide." -policyCreated = "Policy created" -promoteToPolicy = "Promote to policy" - -[pipelines.promoted.table] -sourceDocType = "Source doc type" -watchFolder = "Watch folder" -status = "Status" - -[pipelines.promoted.status] -deployed = "Deployed" -staged = "Staged" -review = "Needs review" - -[pipelines.table.header] -name = "Pipeline" -health = "Health" -goldenSet = "Golden set" -docs24h = "Docs / 24h" -version = "Version" +description = "Create your first pipeline: pick the sources it runs over, chain the operations, and choose where output goes." +action = "Create a pipeline" [pipelines.table] -boundTooltip = "Bound: {{bound}}" +name = "Pipeline" +status = "Status" +steps = "Steps" +sources = "Sources" -[pipelines.metrics] -docs24h = "Docs / 24h" -throughput = "Throughput" -errorRate = "Error rate" -p95Latency = "P95 latency" -uptime = "Uptime" +[pipelines.detail] +subtitle = "{{trigger}} · {{status}}" +closeAriaLabel = "Close detail" +steps = "Operations" +noSteps = "No operations configured." +sources = "Sources" +noSources = "No sources. Files are supplied directly to each run." +output = "Output" +run = "Run now" +edit = "Edit" +pause = "Pause" +resume = "Resume" +delete = "Delete pipeline" -[pipelines.card] -stageTooltip_one = "{{label}}: {{count}} op" -stageTooltip_other = "{{label}}: {{count}} ops" -golden = "Golden {{passing}}/{{total}}" -drift_one = "{{count}} drift" -drift_other = "{{count}} drifts" +[pipelines.run] +empty = "Nothing to run: the sources had no documents to process." +failed = "Run failed: {{error}}" +running = "Run started; still in progress." +completed_one = "Run completed." +completed_other = "All {{count}} runs completed." + +[pipelines.delete] +title = "Delete pipeline?" +body = "Delete \"{{name}}\"? This can't be undone." +cancel = "Cancel" +confirm = "Delete" [pipelines.composer] title = "New pipeline" -subtitle = "Pick a source, compose the operation chain, then route the output." +editTitle = "Edit pipeline" +subtitle = "Pick the sources it runs over, chain the operations, then choose when it runs and where output goes." cancel = "Cancel" -back = "Back" -deploy = "Deploy pipeline" -continue = "Continue" -quickAddBundles = "Quick-add bundles" -chainEmpty = "Add operations from the library below." -operationChain_one = "Operation chain ({{count}})" -operationChain_other = "Operation chain ({{count}})" -destination = "Destination" -alerts = "Alerts" +create = "Create pipeline" +save = "Save changes" +name = "Name" +namePlaceholder = "e.g. Redaction sweep" +sources = "Sources" +sourcesLoading = "Loading sources..." +noSources = "No sources connected yet. The pipeline can still run on files supplied to it directly." +operations_one = "Operation ({{count}})" +operations_other = "Operations ({{count}})" +chainEmpty = "Add operations from the palette below." +moveUp = "Move up" +moveDown = "Move down" +removeStep = "Remove operation" +trigger = "Trigger" +triggerManual = "Manual only" +scheduleEvery = "Run every" +output = "Output" +directory = "Output folder" +directoryHelp = "Absolute path on the server. Must be within the configured allowed folders." -[pipelines.composer.steps] -source = "Source" -operations = "Operations" -routing = "Routing" - -[pipelines.composer.anySource] -label = "Any source" -desc = "Accept documents from every connected channel" - -[pipelines.composer.opKind] -ingest = "Ingest" -validate = "Validate" -modify = "Modify" -secure = "Secure" -store = "Route / Store" -alert = "Alerts" - -[pipelines.composer.alert.email] -title = "Email on failure" -desc = "Notify the on-call list when error rate trips its bound" - -[pipelines.composer.alert.webhook] -title = "Webhook on completion" -desc = "POST a run summary to a URL you control" - -[pipelines.composer.alert.review] -title = "Route low-confidence to review" -desc = "Send docs under the confidence bound to a human queue" - -[pipelines.detail] -subtitle = "{{version}} · {{source}} → {{destination}}" - -[pipelines.detail.stages] -heading = "Pipeline stages" -description = "Every document flows through five stages between {{source}} and {{destination}}." -noOps = "No ops" - -[pipelines.detail.golden] -heading = "Golden-set validation" -passing = "{{passing}} of {{total}} passing" -lastRun = "last run {{lastRun}}" -barLabel = "Golden set {{passing}} of {{total}} passing" - -[pipelines.detail.drift] -heading = "Schema drift" -confidence = "{{delta}} conf" -docs_one = "{{count}} docs" -docs_other = "{{count}} docs" - -[pipelines.detail.drift.empty] -title = "No drift detected" -description = "Every document in the last 24h matched its inferred schema." +[pipelines.composer.unit] +minutes = "minutes" +hours = "hours" +days = "days" [sources] title = "Sources" diff --git a/frontend/portal/src/api/pipelines.ts b/frontend/portal/src/api/pipelines.ts index e7bb6222b6..20f26fccc6 100644 --- a/frontend/portal/src/api/pipelines.ts +++ b/frontend/portal/src/api/pipelines.ts @@ -1,39 +1,170 @@ import { apiClient } from "@portal/api/http"; -import type { PipelinesResponse } from "@portal/mocks/pipelines"; -import type { Tier } from "@portal/contexts/TierContext"; -export type { - EvalsNote, - GoldenSet, - Pipeline, - PipelineMetrics, - PipelinesResponse, - PipelineStatus, - PromotedPipeline, - PromotedStatus, - SchemaDrift, - StageKey, - StageSummary, -} from "@portal/mocks/pipelines"; +/** + * Pipelines service layer: the backend contract. + * + * A "pipeline" in the portal IS a backend policy (PolicyController, Policy.java): + * an ordered chain of tool steps with input sources, a trigger, and an output + * destination. This surface lists EVERY backend policy (the user-facing Policies + * page builds only a friendly subset of the same records). Like Sources, it calls + * the REAL Stirling API base `/api/v1/policies`, so dropping MSW points these exact + * calls at the live backend. + */ -/** GET /v1/pipelines?tier=… — the deployed fleet plus tier-specific extras. */ -export async function fetchPipelines(tier: Tier): Promise { - return apiClient.local.json( - `/v1/pipelines?tier=${encodeURIComponent(tier)}`, - ); +/** One tool invocation in a pipeline. `operation` is a Stirling endpoint path. */ +export interface PipelineStep { + operation: string; + parameters: Record; + fileParameters?: Record; +} + +/** When a policy fires automatically. `type` keys a trigger bean (e.g. "schedule"). */ +export interface TriggerConfig { + type: string; + options: Record; +} + +/** Where a run's outputs are delivered. `type` keys an output sink (e.g. "inline"). */ +export interface OutputSpec { + type: string; + options: Record; } /** - * Promote a watch-folder-derived pipeline into a governed org policy, so its - * rules apply fleet-wide instead of just to the originating flow. - * - * TODO(backend): POST /v1/pipelines/{id}/promote-to-policy — should create the - * policy from the pipeline's stages and return the new policy id. The mock - * handler resolves `{ ok: true }`; the UI treats a resolved promise as accepted. + * The stored policy record: the create/update body (`id` blank on create) and what + * the backend returns from GET/POST. Mirrors Policy.java exactly; `owner`/`teamId` + * are stamped server-side. A `null` trigger means manual-only. */ -export async function promoteToPolicy(id: string): Promise<{ ok: true }> { - return apiClient.local.json<{ ok: true }>( - `/v1/pipelines/${encodeURIComponent(id)}/promote-to-policy`, +export interface Policy { + id?: string; + name: string; + owner?: string | null; + enabled: boolean; + trigger: TriggerConfig | null; + sourceIds: string[]; + steps: PipelineStep[]; + output: OutputSpec; + teamId?: number | null; +} + +/** Overview row status: enabled (fires automatically) or paused. */ +export type PipelineStatus = "active" | "paused"; + +/** A source a pipeline pulls documents from, resolved to its display name. */ +export interface PipelineSourceRef { + id: string; + name: string; +} + +/** One row in the Pipelines overview. Mirrors the backend `PolicyView`. */ +export interface PipelineView { + id: string; + name: string; + enabled: boolean; + status: PipelineStatus; + /** Trigger summary: "manual" or the trigger type (e.g. "schedule"). */ + trigger: string; + sources: PipelineSourceRef[]; + /** Operation endpoint paths, in run order. */ + steps: string[]; + /** Output sink type (e.g. "inline", "folder"). */ + output: string; + owner: string; +} + +export interface PipelineKpi { + value: number; + description: string; +} + +export interface PipelinesOverviewResponse { + kpis: PipelineKpi[]; + pipelines: PipelineView[]; +} + +/** A trigger kind and the source types it works with. Mirrors the backend `TriggerInfo`. */ +export interface TriggerInfo { + /** Matches `TriggerConfig.type` (e.g. "schedule", "folder-watch"). */ + type: string; + /** Whether the trigger needs at least one compatible source to function. */ + requiresSource: boolean; + /** Source types it supports; empty means source-agnostic (no constraint). */ + supportedSourceTypes: string[]; +} + +export type PolicyRunStatus = + | "PENDING" + | "RUNNING" + | "WAITING_FOR_INPUT" + | "COMPLETED" + | "FAILED" + | "CANCELLED"; + +/** A run's current state. Mirrors the backend `PolicyRunView` (outputs elided). */ +export interface PolicyRunView { + runId: string; + policyId: string | null; + status: PolicyRunStatus; + currentStep: number; + stepCount: number; + /** Human-readable failure message; set when status is FAILED. */ + error: string | null; + errorCode: string | null; + createdAt: number; +} + +/** GET /api/v1/policies/overview: KPI strip + one row per policy for the admin. */ +export async function fetchPipelines(): Promise { + return apiClient.local.json( + "/api/v1/policies/overview", + ); +} + +/** GET /api/v1/policies/{id}: the raw policy record (steps, sources, trigger), for editing. */ +export async function fetchPipeline(id: string): Promise { + return apiClient.local.json( + `/api/v1/policies/${encodeURIComponent(id)}`, + ); +} + +/** POST /api/v1/policies: create (blank id) or update (matched id) a policy. */ +export async function savePipeline(policy: Policy): Promise { + return apiClient.local.json("/api/v1/policies", { + method: "POST", + body: policy, + }); +} + +/** DELETE /api/v1/policies/{id}: remove a policy. */ +export async function deletePipeline(id: string): Promise { + await apiClient.local.json( + `/api/v1/policies/${encodeURIComponent(id)}`, + { + method: "DELETE", + }, + ); +} + +/** GET /api/v1/policies/triggers: available triggers + their source compatibility. */ +export async function fetchTriggers(): Promise { + return apiClient.local.json("/api/v1/policies/triggers"); +} + +/** + * POST /api/v1/policies/{id}/trigger: run the pipeline now against its configured + * sources, regardless of the enabled flag. Returns the ids of the runs started + * (empty when the sources yielded no work); poll {@link fetchRun} for each. + */ +export async function triggerPipeline(id: string): Promise { + return apiClient.local.json( + `/api/v1/policies/${encodeURIComponent(id)}/trigger`, { method: "POST" }, ); } + +/** GET /api/v1/policies/run/{runId}: current status, error, and step cursor of a run. */ +export async function fetchRun(runId: string): Promise { + return apiClient.local.json( + `/api/v1/policies/run/${encodeURIComponent(runId)}`, + ); +} diff --git a/frontend/portal/src/components/pipelines/DeployedPipelinesTable.stories.tsx b/frontend/portal/src/components/pipelines/DeployedPipelinesTable.stories.tsx deleted file mode 100644 index 3dc731a47a..0000000000 --- a/frontend/portal/src/components/pipelines/DeployedPipelinesTable.stories.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { DeployedPipelinesTable } from "@portal/components/pipelines/DeployedPipelinesTable"; -import { - DEGRADED_PIPELINE, - HEALTHY_PIPELINE, -} from "@portal/components/pipelines/storyFixtures"; -import "@portal/views/Pipelines.css"; - -const meta: Meta = { - title: "Portal/Pipelines/DeployedPipelinesTable", - component: DeployedPipelinesTable, - parameters: { layout: "padded" }, - args: { onRowClick: () => {} }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -/** A healthy pipeline at bound and one degraded below its golden-set bound. */ -export const Default: Story = { - args: { pipelines: [HEALTHY_PIPELINE, DEGRADED_PIPELINE] }, -}; - -export const Empty: Story = { - args: { pipelines: [] }, -}; diff --git a/frontend/portal/src/components/pipelines/DeployedPipelinesTable.tsx b/frontend/portal/src/components/pipelines/DeployedPipelinesTable.tsx deleted file mode 100644 index 03d2e6f015..0000000000 --- a/frontend/portal/src/components/pipelines/DeployedPipelinesTable.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { StatusBadge, Table, type TableColumn } from "@shared/components"; -import type { Pipeline } from "@portal/api/pipelines"; -import { compact, goldenTone, pct } from "@portal/components/pipelines/format"; - -interface DeployedPipelinesTableProps { - pipelines: Pipeline[]; - onRowClick: (p: Pipeline) => void; -} - -/** - * Dense roster of the deployed fleet that puts golden-set reliability up front. - * The card list below it carries the full per-pipeline story; this table is the - * scannable "is anything below its bound?" view across the whole fleet. - */ -export function DeployedPipelinesTable({ - pipelines, - onRowClick, -}: DeployedPipelinesTableProps) { - const { t } = useTranslation(); - const columns = useMemo[]>( - () => [ - { - key: "name", - header: t("pipelines.table.header.name"), - render: (p) => ( -
- {p.name} - - {p.source} → {p.destination} - -
- ), - }, - { - key: "status", - header: t("pipelines.table.header.health"), - render: (p) => ( - - {p.status === "degraded" - ? t("pipelines.status.degraded") - : t("pipelines.status.healthy")} - - ), - }, - { - key: "golden", - header: t("pipelines.table.header.goldenSet"), - width: "11rem", - render: (p) => { - const tone = goldenTone(p.golden); - const rate = p.golden.total ? p.golden.passing / p.golden.total : 0; - return ( -
- - {p.golden.passing}/{p.golden.total} - - - {pct(rate, 1)} - -
- ); - }, - }, - { - key: "docs", - header: t("pipelines.table.header.docs24h"), - align: "right", - render: (p) => ( - - {compact(p.metrics.docs24h)} - - ), - }, - { - key: "version", - header: t("pipelines.table.header.version"), - align: "right", - render: (p) => ( - {p.version} - ), - }, - ], - [t], - ); - - return ( - - className="portal-pipelines__roster" - columns={columns} - rows={pipelines} - rowKey={(p) => p.id} - onRowClick={onRowClick} - /> - ); -} diff --git a/frontend/portal/src/components/pipelines/KpiStrip.tsx b/frontend/portal/src/components/pipelines/KpiStrip.tsx new file mode 100644 index 0000000000..6acab35912 --- /dev/null +++ b/frontend/portal/src/components/pipelines/KpiStrip.tsx @@ -0,0 +1,39 @@ +import { useTranslation } from "react-i18next"; +import { MetricCard, MetricStrip } from "@shared/components"; +import type { PipelinesOverviewResponse } from "@portal/api/pipelines"; + +/** + * KPI labels are product copy: they describe what each metric IS, not its value. + * They stay client-side so the strip's structure is stable across loading / empty + * / ready states; only values + descriptions flow from the API. Order matches + * PolicyOverviewService.buildKpis: total, active, paused. + */ +const KPI_LABEL_KEYS = [ + "pipelines.kpi.total", + "pipelines.kpi.active", + "pipelines.kpi.paused", +] as const; + +interface KpiStripProps { + data: PipelinesOverviewResponse | null; + loading: boolean; +} + +export function KpiStrip({ data, loading }: KpiStripProps) { + const { t } = useTranslation(); + return ( + + {KPI_LABEL_KEYS.map((labelKey, i) => { + const k = loading ? undefined : data?.kpis[i]; + return ( + + ); + })} + + ); +} diff --git a/frontend/portal/src/components/pipelines/PipelineCard.stories.tsx b/frontend/portal/src/components/pipelines/PipelineCard.stories.tsx deleted file mode 100644 index 0df2755352..0000000000 --- a/frontend/portal/src/components/pipelines/PipelineCard.stories.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { PipelineCard } from "@portal/components/pipelines/PipelineCard"; -import { - DEGRADED_PIPELINE, - HEALTHY_PIPELINE, -} from "@portal/components/pipelines/storyFixtures"; - -const meta: Meta = { - title: "Portal/Pipelines/PipelineCard", - component: PipelineCard, - parameters: { layout: "padded" }, - args: { onOpen: () => console.log("open") }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -export const Healthy: Story = { - args: { pipeline: HEALTHY_PIPELINE }, -}; - -export const DegradedWithDrift: Story = { - args: { pipeline: DEGRADED_PIPELINE }, -}; diff --git a/frontend/portal/src/components/pipelines/PipelineCard.tsx b/frontend/portal/src/components/pipelines/PipelineCard.tsx deleted file mode 100644 index 596ec8cd86..0000000000 --- a/frontend/portal/src/components/pipelines/PipelineCard.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Card, StatTile, StatusBadge } from "@shared/components"; -import type { Pipeline, StageSummary } from "@portal/api/pipelines"; -import { - STAGE_ACCENT, - STAGE_COLOR_VAR, -} from "@portal/components/pipelines/stageAccent"; -import { compact, pct } from "@portal/components/pipelines/format"; - -/** Compact five-dot stage indicator: a lit dot per stage that has ops. */ -function StageDots({ stages }: { stages: StageSummary[] }) { - const { t } = useTranslation(); - return ( - - {stages.map((s) => ( - - ))} - - ); -} - -export interface PipelineCardProps { - pipeline: Pipeline; - onOpen: (p: Pipeline) => void; -} - -/** Row in the deployed fleet: health, source→stages→destination rail, 24h metrics. */ -export function PipelineCard({ pipeline, onOpen }: PipelineCardProps) { - const { t } = useTranslation(); - const m = pipeline.metrics; - const degraded = pipeline.status === "degraded"; - const errorTone = - m.errorRate >= 0.02 - ? "danger" - : m.errorRate >= 0.01 - ? "warning" - : "default"; - const driftCount = pipeline.drift.length; - - return ( - onOpen(pipeline)} - > -
-
-

{pipeline.name}

-

{pipeline.blurb}

-
- - {degraded - ? t("pipelines.status.degraded") - : t("pipelines.status.healthy")} - -
- -
- {pipeline.source} - - → - - - - → - - - {pipeline.destination} - -
- -
- - - - - -
- -
- - {pipeline.version} · {pipeline.regions.join(", ")} - - - {t("pipelines.card.golden", { - passing: pipeline.golden.passing, - total: pipeline.golden.total, - })} - {driftCount > 0 && ( - - {" · "} - {t("pipelines.card.drift", { count: driftCount })} - - )} - -
-
- ); -} diff --git a/frontend/portal/src/components/pipelines/PipelineComposer.stories.tsx b/frontend/portal/src/components/pipelines/PipelineComposer.stories.tsx deleted file mode 100644 index 34a992f0ea..0000000000 --- a/frontend/portal/src/components/pipelines/PipelineComposer.stories.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { PipelineComposer } from "@portal/components/pipelines/PipelineComposer"; - -const meta: Meta = { - title: "Portal/Pipelines/PipelineComposer", - component: PipelineComposer, - parameters: { layout: "fullscreen" }, - args: { open: true, onClose: () => console.log("close") }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -/** Opens on the source step; step through Operations and Routing in the footer. */ -export const Open: Story = {}; - -export const Closed: Story = { - args: { open: false }, -}; diff --git a/frontend/portal/src/components/pipelines/PipelineComposer.tsx b/frontend/portal/src/components/pipelines/PipelineComposer.tsx index b57c9eaa87..94546704c7 100644 --- a/frontend/portal/src/components/pipelines/PipelineComposer.tsx +++ b/frontend/portal/src/components/pipelines/PipelineComposer.tsx @@ -1,349 +1,473 @@ -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Button, Chip, Modal } from "@shared/components"; import { - DESTINATION_OPTIONS, - PIPELINE_OPS, - PIPELINE_AGENTS, - SOURCE_OPTIONS, - type OpKind, - type PipelineOp, -} from "@shared/data/ops"; + Banner, + Button, + Checkbox, + Chip, + FormField, + Input, + Modal, + RadioGroup, + Select, +} from "@shared/components"; +import { errorMessage } from "@portal/api/http"; import { - OP_KIND_ACCENT, - STAGE_COLOR_VAR, -} from "@portal/components/pipelines/stageAccent"; + fetchTriggers, + savePipeline, + type OutputSpec, + type PipelineStep, + type Policy, + type TriggerConfig, + type TriggerInfo, +} from "@portal/api/pipelines"; +import { fetchSources, type SourceView } from "@portal/api/sources"; +import { useAsync } from "@portal/hooks/useAsync"; +import { + PIPELINE_OPERATIONS, + humanizeOperation, +} from "@portal/components/pipelines/pipelineOperations"; +import "@portal/views/Pipelines.css"; -const COMPOSER_STEPS = ["source", "operations", "routing"] as const; +type OutputMode = "inline" | "folder"; +type ScheduleUnit = "MINUTES" | "HOURS" | "DAYS"; -/** Translation key suffixes for each op-kind group heading in the picker. */ -const OP_KIND_LABEL_KEY: Record = { - ingest: "ingest", - validate: "validate", - modify: "modify", - secure: "secure", - store: "store", - alert: "alert", -}; +const SCHEDULE_UNITS: ScheduleUnit[] = ["MINUTES", "HOURS", "DAYS"]; +/** Empty trigger type = manual-only (no automatic trigger). */ +const MANUAL = ""; -/** Selectable ops in the picker — excludes pipeline-only structural ops. */ -const PICKER_OPS: Record = (() => { - const out = {} as Record; - for (const kind of Object.keys(PIPELINE_OPS) as OpKind[]) { - out[kind] = PIPELINE_OPS[kind].filter((op) => !op.pipelineOnly); - } - return out; -})(); - -function lookupPickerOp(id: string): PipelineOp | null { - for (const kind of Object.keys(PICKER_OPS) as OpKind[]) { - const found = PICKER_OPS[kind].find((op) => op.id === id); - if (found) return found; - } - return null; -} - -export interface PipelineComposerProps { +interface PipelineComposerProps { open: boolean; onClose: () => void; + /** Called after a pipeline is created or updated so the page can refetch. */ + onSaved: () => void; + /** When set, the composer edits this existing policy instead of creating one. */ + pipeline?: Policy; } -/** Three-step wizard: pick a source, compose the op chain, route the output. */ -export function PipelineComposer({ open, onClose }: PipelineComposerProps) { +/** + * A policy's trigger parsed into the composer's fields: which trigger type (empty + * = manual), and the schedule interval when it's a schedule trigger. + */ +function parseTrigger(trigger: TriggerConfig | null): { + triggerType: string; + count: string; + unit: ScheduleUnit; +} { + if (!trigger) return { triggerType: MANUAL, count: "1", unit: "HOURS" }; + if (trigger.type === "schedule") { + const schedule = trigger.options?.schedule as + | { type?: string; count?: number; unit?: ScheduleUnit } + | undefined; + if (schedule?.type === "every") { + return { + triggerType: "schedule", + count: String(schedule.count ?? 1), + unit: schedule.unit ?? "HOURS", + }; + } + // Non-interval schedules (daily/weekly/monthly) aren't editable here; show + // the schedule choice with defaults the user can re-set. + return { triggerType: "schedule", count: "1", unit: "HOURS" }; + } + return { triggerType: trigger.type, count: "1", unit: "HOURS" }; +} + +/** Output sink fields parsed from a policy's output for editing. */ +function parseOutput(output: OutputSpec | undefined): { + mode: OutputMode; + directory: string; +} { + if (output?.type === "folder") { + return { + mode: "folder", + directory: String(output.options?.directory ?? ""), + }; + } + return { mode: "inline", directory: "" }; +} + +/** + * Compose a pipeline (a backend policy): name it, pick the sources it pulls from + * and how it's triggered, chain operations, and choose where output goes. On submit + * a blank id creates and a set id updates, matching the backend's POST contract. + * Per-operation parameter editing is out of scope here; operations are chained with + * their defaults. + */ +export function PipelineComposer({ + open, + onClose, + onSaved, + pipeline, +}: PipelineComposerProps) { const { t } = useTranslation(); - const [step, setStep] = useState(0); - const [source, setSource] = useState("upload"); - const [selectedOps, setSelectedOps] = useState([ - "extract", - "validate", - "redact", - ]); - const [destination, setDestination] = useState("vault"); - const [notifyEmail, setNotifyEmail] = useState(true); - const [notifyWebhook, setNotifyWebhook] = useState(false); - const [reviewQueue, setReviewQueue] = useState(true); + const isEdit = pipeline !== undefined; - function reset() { - setStep(0); - setSource("upload"); - setSelectedOps(["extract", "validate", "redact"]); - setDestination("vault"); - setNotifyEmail(true); - setNotifyWebhook(false); - setReviewQueue(true); - } + const sourcesState = useAsync( + async () => (open ? (await fetchSources()).sources : []), + [open], + ); + const availableSources = sourcesState.data ?? []; - function close() { - onClose(); - // Defer reset so it doesn't flash mid-close-animation. - setTimeout(reset, 0); - } + // The triggers the backend supports, with their source-type compatibility, so + // the UI offers them (and pairs them with sources) without hard-coding the set. + const triggersState = useAsync( + async () => (open ? await fetchTriggers() : []), + [open], + ); + const triggers = useMemo( + () => triggersState.data ?? [], + [triggersState.data], + ); - function deploy() { - // TODO(backend): POST /v1/pipelines { source, ops: selectedOps, destination, alerts } - close(); - } + const [name, setName] = useState(""); + const [sourceIds, setSourceIds] = useState([]); + const [steps, setSteps] = useState([]); + const [triggerType, setTriggerType] = useState(MANUAL); + const [scheduleCount, setScheduleCount] = useState("1"); + const [scheduleUnit, setScheduleUnit] = useState("HOURS"); + const [outputMode, setOutputMode] = useState("inline"); + const [outputDirectory, setOutputDirectory] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); - function toggleOp(id: string) { - setSelectedOps((prev) => - prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], + // Re-seed the form whenever the composer opens (or its target changes) so editing + // prefills the current config and a reopened create starts clean. + useEffect(() => { + if (!open) return; + const trigger = parseTrigger(pipeline?.trigger ?? null); + const output = parseOutput(pipeline?.output); + setName(pipeline?.name ?? ""); + setSourceIds(pipeline?.sourceIds ?? []); + setSteps(pipeline?.steps ?? []); + setTriggerType(trigger.triggerType); + setScheduleCount(trigger.count); + setScheduleUnit(trigger.unit); + setOutputMode(output.mode); + setOutputDirectory(output.directory); + setSubmitting(false); + setError(null); + }, [open, pipeline]); + + // Types of the currently-selected sources, for trigger compatibility. + const selectedSourceTypes = useMemo( + () => + new Set( + availableSources + .filter((s) => sourceIds.includes(s.id)) + .map((s) => s.type), + ), + [availableSources, sourceIds], + ); + + const triggerAvailable = useMemo( + () => (trigger: TriggerInfo) => + !trigger.requiresSource || + trigger.supportedSourceTypes.some((type) => + selectedSourceTypes.has(type), + ), + [selectedSourceTypes], + ); + + // A source-requiring trigger stops being valid the moment its compatible source + // is deselected; fall back to manual so we never submit an impossible trigger. + useEffect(() => { + if (triggerType === MANUAL) return; + const selected = triggers.find((trigger) => trigger.type === triggerType); + if (selected && !triggerAvailable(selected)) setTriggerType(MANUAL); + }, [triggerType, triggers, triggerAvailable]); + + function toggleSource(id: string, checked: boolean) { + setSourceIds((ids) => + checked ? [...ids, id] : ids.filter((existing) => existing !== id), ); } - function applyAgent(ops: string[]) { - // A bundle lists its full op set, but structural rails (retention, - // residency, access policy) aren't user-chainable picker ops — add only - // ops that exist in the picker so every chain chip resolves to a label. - const pickable = ops.filter((id) => lookupPickerOp(id) !== null); - setSelectedOps((prev) => Array.from(new Set([...prev, ...pickable]))); + function addStep(operation: string, parameters: Record) { + setSteps((current) => [ + ...current, + { operation, parameters: { ...parameters } }, + ]); } - const isLast = step === COMPOSER_STEPS.length - 1; - const canAdvance = step === 1 ? selectedOps.length > 0 : true; + function removeStep(index: number) { + setSteps((current) => current.filter((_, i) => i !== index)); + } + + function moveStep(index: number, delta: number) { + setSteps((current) => { + const next = [...current]; + const target = index + delta; + if (target < 0 || target >= next.length) return current; + [next[index], next[target]] = [next[target], next[index]]; + return next; + }); + } + + const scheduleCountValid = + triggerType !== "schedule" || Number(scheduleCount) > 0; + const outputValid = outputMode !== "folder" || outputDirectory.trim() !== ""; + const canSave = + name.trim() !== "" && scheduleCountValid && outputValid && !submitting; + + const triggerOptions = [ + { value: MANUAL, label: t("pipelines.composer.triggerManual") }, + ...triggers.map((trigger) => ({ + value: trigger.type, + label: t(`pipelines.trigger.${trigger.type}`, { + defaultValue: trigger.type, + }), + disabled: !triggerAvailable(trigger), + })), + ]; + + function buildTrigger(): TriggerConfig | null { + if (triggerType === MANUAL) return null; + if (triggerType === "schedule") { + return { + type: "schedule", + options: { + schedule: { + type: "every", + count: Number(scheduleCount), + unit: scheduleUnit, + }, + }, + }; + } + return { type: triggerType, options: {} }; + } + + async function submit() { + if (!canSave) return; + setSubmitting(true); + setError(null); + const output: OutputSpec = + outputMode === "folder" + ? { type: "folder", options: { directory: outputDirectory.trim() } } + : { type: "inline", options: {} }; + const policy: Policy = { + id: pipeline?.id, + name: name.trim(), + enabled: pipeline?.enabled ?? true, + trigger: buildTrigger(), + sourceIds, + steps, + output, + }; + try { + await savePipeline(policy); + onSaved(); + onClose(); + } catch (e) { + setError(errorMessage(e)); + } finally { + setSubmitting(false); + } + } return ( -
- {COMPOSER_STEPS.map((stepId, i) => ( - - {i + 1}. {t(`pipelines.composer.steps.${stepId}`)} - - ))} -
- - {step > 0 && ( - - )} - {isLast ? ( - - ) : ( - - )} - + + } >
- {step === 0 && ( -
-
- - ))} -
- - - {t("pipelines.composer.alerts")} - -
- - - -
-
- )} + {error && }
); diff --git a/frontend/portal/src/components/pipelines/PipelineDetail.stories.tsx b/frontend/portal/src/components/pipelines/PipelineDetail.stories.tsx deleted file mode 100644 index a7c6c7763e..0000000000 --- a/frontend/portal/src/components/pipelines/PipelineDetail.stories.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { PipelineDetail } from "@portal/components/pipelines/PipelineDetail"; -import { - DEGRADED_PIPELINE, - HEALTHY_PIPELINE, -} from "@portal/components/pipelines/storyFixtures"; - -const meta: Meta = { - title: "Portal/Pipelines/PipelineDetail", - component: PipelineDetail, - parameters: { layout: "padded" }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -/** Clean golden set, no drift. */ -export const Healthy: Story = { - args: { pipeline: HEALTHY_PIPELINE }, -}; - -/** Failing golden cases plus warning- and info-severity schema drift. */ -export const DegradedWithDrift: Story = { - args: { pipeline: DEGRADED_PIPELINE }, -}; diff --git a/frontend/portal/src/components/pipelines/PipelineDetail.tsx b/frontend/portal/src/components/pipelines/PipelineDetail.tsx deleted file mode 100644 index cd24e3bc1a..0000000000 --- a/frontend/portal/src/components/pipelines/PipelineDetail.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { - Chip, - EmptyState, - ProgressBar, - StatTile, - StatusBadge, -} from "@shared/components"; -import type { Pipeline, SchemaDrift } from "@portal/api/pipelines"; -import { - STAGE_ACCENT, - STAGE_COLOR_VAR, -} from "@portal/components/pipelines/stageAccent"; -import { compact, pct } from "@portal/components/pipelines/format"; - -function DriftRow({ drift }: { drift: SchemaDrift }) { - const { t } = useTranslation(); - const confDelta = - (drift.confidenceDelta > 0 ? "+" : "") + drift.confidenceDelta.toFixed(2); - return ( -
  • - -
    - {drift.field} - {drift.note} -
    -
    - - {t("pipelines.detail.drift.confidence", { delta: confDelta })} - - - {t("pipelines.detail.drift.docs", { count: drift.affectedDocs })} - -
    -
  • - ); -} - -export interface PipelineDetailProps { - pipeline: Pipeline; -} - -/** Drawer body: 24h metrics, the five stages, golden-set health, and schema drift. */ -export function PipelineDetail({ pipeline }: PipelineDetailProps) { - const { t } = useTranslation(); - const m = pipeline.metrics; - const goldenRatio = pipeline.golden.total - ? pipeline.golden.passing / pipeline.golden.total - : 0; - const goldenClean = pipeline.golden.passing === pipeline.golden.total; - - return ( -
    -
    - - - - - -
    - -
    -

    - {t("pipelines.detail.stages.heading")} -

    -

    - {t("pipelines.detail.stages.description", { - source: pipeline.source, - destination: pipeline.destination, - })} -

    -
    - {pipeline.stages.map((stage) => { - const accent = STAGE_ACCENT[stage.key]; - return ( -
    -
    - - - {stage.label} - -
    -
    - {stage.ops.length === 0 ? ( - - {t("pipelines.detail.stages.noOps")} - - ) : ( - stage.ops.map((op) => ( - - {op} - - )) - )} -
    -
    - ); - })} -
    -
    - -
    -

    - {t("pipelines.detail.golden.heading")} -

    -
    -
    - - {t("pipelines.detail.golden.passing", { - passing: pipeline.golden.passing, - total: pipeline.golden.total, - })} - - - {t("pipelines.detail.golden.lastRun", { - lastRun: pipeline.golden.lastRun, - })} - -
    - -
    -
    - -
    -

    - {t("pipelines.detail.drift.heading")} -

    - {pipeline.drift.length === 0 ? ( - - ) : ( -
      - {pipeline.drift.map((d) => ( - - ))} -
    - )} -
    -
    - ); -} diff --git a/frontend/portal/src/components/pipelines/PipelineDetailCard.tsx b/frontend/portal/src/components/pipelines/PipelineDetailCard.tsx new file mode 100644 index 0000000000..3e196a3dea --- /dev/null +++ b/frontend/portal/src/components/pipelines/PipelineDetailCard.tsx @@ -0,0 +1,213 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Banner, Button, Chip } from "@shared/components"; +import { errorMessage } from "@portal/api/http"; +import { + fetchRun, + triggerPipeline, + type PipelineView, + type PolicyRunView, +} from "@portal/api/pipelines"; +import { humanizeOperation } from "@portal/components/pipelines/pipelineOperations"; +import "@portal/views/Pipelines.css"; + +const TERMINAL_STATUSES = new Set(["COMPLETED", "FAILED", "CANCELLED"]); +const POLL_INTERVAL_MS = 1500; +const POLL_ATTEMPTS = 60; + +type RunResult = { tone: "success" | "danger" | "info"; text: string }; + +interface PipelineDetailCardProps { + pipeline: PipelineView; + onClose: () => void; + onEdit: (pipeline: PipelineView) => void; + onTogglePause: (pipeline: PipelineView) => void; + onDelete: (pipeline: PipelineView) => void; + /** Disables the actions while a page-level mutation is in flight. */ + busy?: boolean; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Expanded detail for the selected pipeline row, with run/edit/pause/delete actions. */ +export function PipelineDetailCard({ + pipeline, + onClose, + onEdit, + onTogglePause, + onDelete, + busy = false, +}: PipelineDetailCardProps) { + const { t } = useTranslation(); + const paused = pipeline.status === "paused"; + + const [running, setRunning] = useState(false); + const [runResult, setRunResult] = useState(null); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + // Poll a run until it reaches a terminal state (or we give up), so a failure + // during execution surfaces with its error message rather than silently. + async function awaitRun(runId: string): Promise { + for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) { + if (!mounted.current) return null; + const view = await fetchRun(runId); + if (TERMINAL_STATUSES.has(view.status)) return view; + await sleep(POLL_INTERVAL_MS); + } + return null; + } + + async function handleRun() { + if (running || busy) return; + setRunning(true); + setRunResult(null); + try { + const runIds = await triggerPipeline(pipeline.id); + if (runIds.length === 0) { + if (mounted.current) + setRunResult({ tone: "info", text: t("pipelines.run.empty") }); + return; + } + const finals = await Promise.all(runIds.map((id) => awaitRun(id))); + if (!mounted.current) return; + const failed = finals.find((r) => r?.status === "FAILED"); + if (failed) { + setRunResult({ + tone: "danger", + text: t("pipelines.run.failed", { error: failed.error ?? "" }), + }); + } else if (finals.every((r) => r?.status === "COMPLETED")) { + setRunResult({ + tone: "success", + text: t("pipelines.run.completed", { count: finals.length }), + }); + } else { + // Still running when we stopped polling, or cancelled. + setRunResult({ tone: "info", text: t("pipelines.run.running") }); + } + } catch (e) { + if (mounted.current) + setRunResult({ tone: "danger", text: errorMessage(e) }); + } finally { + if (mounted.current) setRunning(false); + } + } + + return ( +
    +
    + + ⛓ + +
    +

    {pipeline.name}

    + + {t("pipelines.detail.subtitle", { + trigger: t(`pipelines.trigger.${pipeline.trigger}`, { + defaultValue: pipeline.trigger, + }), + status: t(`pipelines.status.${pipeline.status}`), + })} + +
    + +
    + +
    +
    + + {t("pipelines.detail.steps")} + + {pipeline.steps.length === 0 ? ( +

    + {t("pipelines.detail.noSteps")} +

    + ) : ( +
    + {pipeline.steps.map((step, i) => ( + + {`${i + 1}. ${humanizeOperation(step)}`} + + ))} +
    + )} +
    + +
    + + {t("pipelines.detail.sources")} + + {pipeline.sources.length === 0 ? ( +

    + {t("pipelines.detail.noSources")} +

    + ) : ( +
    + {pipeline.sources.map((source) => ( + + {source.name} + + ))} +
    + )} +
    + +
    + + {t("pipelines.detail.output")} + + + {t(`pipelines.output.${pipeline.output}`, { + defaultValue: pipeline.output, + })} + +
    +
    + + {runResult && ( + + )} + +
    + + + + +
    +
    + ); +} diff --git a/frontend/portal/src/components/pipelines/PipelineListSkeleton.stories.tsx b/frontend/portal/src/components/pipelines/PipelineListSkeleton.stories.tsx deleted file mode 100644 index ae16b352f7..0000000000 --- a/frontend/portal/src/components/pipelines/PipelineListSkeleton.stories.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { PipelineListSkeleton } from "@portal/components/pipelines/PipelineListSkeleton"; - -const meta: Meta = { - title: "Portal/Pipelines/PipelineListSkeleton", - component: PipelineListSkeleton, - parameters: { layout: "padded" }, - decorators: [ - (S) => ( -
    - -
    - ), - ], -}; -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/portal/src/components/pipelines/PipelineListSkeleton.tsx b/frontend/portal/src/components/pipelines/PipelineListSkeleton.tsx deleted file mode 100644 index 8900ff6990..0000000000 --- a/frontend/portal/src/components/pipelines/PipelineListSkeleton.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Card, Skeleton } from "@shared/components"; - -/** Placeholder fleet while the deployed pipelines load. */ -export function PipelineListSkeleton() { - return ( -
    - {Array.from({ length: 3 }).map((_, i) => ( - -
    - - -
    - - -
    - ))} -
    - ); -} diff --git a/frontend/portal/src/components/pipelines/PipelinesTable.tsx b/frontend/portal/src/components/pipelines/PipelinesTable.tsx new file mode 100644 index 0000000000..6ed9a32004 --- /dev/null +++ b/frontend/portal/src/components/pipelines/PipelinesTable.tsx @@ -0,0 +1,122 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { + Chip, + StatusBadge, + type StatusTone, + Table, + type TableColumn, +} from "@shared/components"; +import type { PipelineStatus, PipelineView } from "@portal/api/pipelines"; + +const STATUS_TONE: Record = { + active: "success", + paused: "neutral", +}; + +interface PipelinesTableProps { + pipelines: PipelineView[]; + /** Id of the row whose detail panel is open, drives the caret state. */ + expandedId: string | null; + onRowClick: (pipeline: PipelineView) => void; +} + +export function PipelinesTable({ + pipelines, + expandedId, + onRowClick, +}: PipelinesTableProps) { + const { t } = useTranslation(); + const columns = useMemo[]>( + () => [ + { + key: "name", + header: t("pipelines.table.name"), + render: (p) => ( +
    + + ⛓ + +
    + {p.name} + + {t(`pipelines.trigger.${p.trigger}`, { + defaultValue: p.trigger, + })} + +
    +
    + ), + }, + { + key: "status", + header: t("pipelines.table.status"), + render: (p) => ( + + {t(`pipelines.status.${p.status}`)} + + ), + }, + { + key: "steps", + header: t("pipelines.table.steps"), + align: "right", + render: (p) => ( + + {p.steps.length} + + ), + }, + { + key: "sources", + header: t("pipelines.table.sources"), + align: "right", + render: (p) => ( + + {p.sources.length} + + ), + }, + { + key: "expand", + header: "", + align: "right", + width: "2.5rem", + render: (p) => ( + + ▸ + + ), + }, + ], + [expandedId, t], + ); + + return ( + + className="portal-pipelines__table" + columns={columns} + rows={pipelines} + rowKey={(p) => p.id} + onRowClick={onRowClick} + /> + ); +} diff --git a/frontend/portal/src/components/pipelines/PromotedPipelines.stories.tsx b/frontend/portal/src/components/pipelines/PromotedPipelines.stories.tsx deleted file mode 100644 index bf52ce686f..0000000000 --- a/frontend/portal/src/components/pipelines/PromotedPipelines.stories.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { PromotedPipelines } from "@portal/components/pipelines/PromotedPipelines"; -import { PROMOTED_PIPELINES } from "@portal/components/pipelines/storyFixtures"; -import "@portal/views/Pipelines.css"; - -const meta: Meta = { - title: "Portal/Pipelines/PromotedPipelines", - component: PromotedPipelines, - parameters: { layout: "padded" }, - args: { promoted: PROMOTED_PIPELINES }, - decorators: [ - (S) => ( -
    - -
    - ), - ], -}; -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { promoted: [] }, -}; diff --git a/frontend/portal/src/components/pipelines/PromotedPipelines.tsx b/frontend/portal/src/components/pipelines/PromotedPipelines.tsx deleted file mode 100644 index 130d128bb6..0000000000 --- a/frontend/portal/src/components/pipelines/PromotedPipelines.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { - Button, - StatusBadge, - type StatusTone, - Table, - type TableColumn, -} from "@shared/components"; -import { - promoteToPolicy, - type PromotedPipeline, - type PromotedStatus, -} from "@portal/api/pipelines"; - -const STATUS_TONE: Record = { - deployed: "success", - staged: "info", - review: "warning", -}; - -/** Translation key suffixes for each promoted-pipeline status badge. */ -const STATUS_LABEL_KEY: Record = { - deployed: "deployed", - staged: "staged", - review: "review", -}; - -/** Per-row promote-to-policy lifecycle, kept local until a backend exists. */ -type PromoteState = "idle" | "pending" | "done"; - -interface PromotedPipelinesProps { - promoted: PromotedPipeline[]; -} - -/** - * Flows that started as Editor watch-folder automations and were promoted into - * the portal. Each keeps a pointer back to the watch folder it grew from, and - * offers a one-click path to lift its rules into a fleet-wide org policy. - */ -export function PromotedPipelines({ promoted }: PromotedPipelinesProps) { - const { t } = useTranslation(); - // Promote submits have no backend yet, so reflect acceptance per row locally. - const [promoteState, setPromoteState] = useState< - Record - >({}); - - const onPromote = async (p: PromotedPipeline) => { - setPromoteState((s) => ({ ...s, [p.id]: "pending" })); - try { - // TODO(backend): POST /v1/pipelines/{id}/promote-to-policy — stubbed, - // resolves against the mock handler; treat success as accepted. - await promoteToPolicy(p.id); - setPromoteState((s) => ({ ...s, [p.id]: "done" })); - } catch { - setPromoteState((s) => ({ ...s, [p.id]: "idle" })); - } - }; - - const columns = useMemo[]>( - () => [ - { - key: "name", - header: t("pipelines.table.header.name"), - render: (p) => ( -
    - {p.name} - - {p.promotedAt} - -
    - ), - }, - { - key: "docType", - header: t("pipelines.promoted.table.sourceDocType"), - render: (p) => ( - - {p.sourceDocType} - - ), - }, - { - key: "watchFolder", - header: t("pipelines.promoted.table.watchFolder"), - render: (p) => ( - - {p.watchFolder} - - ), - }, - { - key: "status", - header: t("pipelines.promoted.table.status"), - render: (p) => ( - - {t(`pipelines.promoted.status.${STATUS_LABEL_KEY[p.status]}`)} - - ), - }, - { - key: "promote", - header: "", - align: "right", - width: "11rem", - render: (p) => { - const state = promoteState[p.id] ?? "idle"; - if (state === "done") { - return ( - - {t("pipelines.promoted.policyCreated")} - - ); - } - return ( - - ); - }, - }, - ], - [promoteState, t], - ); - - return ( - - className="portal-pipelines__promoted" - columns={columns} - rows={promoted} - rowKey={(p) => p.id} - /> - ); -} diff --git a/frontend/portal/src/components/pipelines/format.ts b/frontend/portal/src/components/pipelines/format.ts deleted file mode 100644 index 02267eb8b5..0000000000 --- a/frontend/portal/src/components/pipelines/format.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { GoldenSet } from "@portal/api/pipelines"; -import type { StatusTone } from "@shared/components"; - -/** Fraction → percentage string (0.004 → "0.40%"). */ -export const pct = (n: number, digits = 1) => `${(n * 100).toFixed(digits)}%`; - -/** Compact count for dense metric tiles (28941 → "28.9K"). */ -export const compact = (n: number) => - new Intl.NumberFormat(undefined, { - notation: "compact", - maximumFractionDigits: 1, - }).format(n); - -/** - * Golden-set reliability tone, judged against the pipeline's own pass-rate - * bound. At/above bound is green; a small slip under is amber; a clear miss is - * danger — so a row's reliability reads from colour alone. - */ -export function goldenTone(golden: GoldenSet): StatusTone { - if (golden.total === 0) return "neutral"; - const rate = golden.passing / golden.total; - if (rate >= golden.threshold) return "success"; - // Within five points of the bound is a warning; further off is a hard miss. - if (rate >= golden.threshold - 0.05) return "warning"; - return "danger"; -} diff --git a/frontend/portal/src/components/pipelines/pipelineOperations.ts b/frontend/portal/src/components/pipelines/pipelineOperations.ts new file mode 100644 index 0000000000..2a405e8f1e --- /dev/null +++ b/frontend/portal/src/components/pipelines/pipelineOperations.ts @@ -0,0 +1,50 @@ +/** + * The operation catalogue the composer builds a pipeline from. Each entry's + * `operation` is a real Stirling endpoint path (the backend's PipelineStep + * contract) with sensible default `parameters`, so a composed pipeline is + * runnable as-is. Per-operation parameter editing is intentionally out of scope + * for this version: the composer chains operations with their defaults. Labels + * are derived from the path, so adding an operation needs no translation work. + */ + +export interface PipelineOperationDef { + /** Stirling endpoint path, e.g. "/api/v1/misc/compress-pdf". */ + operation: string; + /** Scalar form fields the endpoint accepts; defaults that keep the step valid. */ + parameters: Record; +} + +export const PIPELINE_OPERATIONS: PipelineOperationDef[] = [ + { operation: "/api/v1/misc/ocr-pdf", parameters: {} }, + { operation: "/api/v1/misc/compress-pdf", parameters: {} }, + { operation: "/api/v1/misc/flatten", parameters: {} }, + { operation: "/api/v1/misc/repair", parameters: {} }, + { + operation: "/api/v1/security/auto-redact", + parameters: { mode: "automatic", convertPDFToImage: true }, + }, + { + operation: "/api/v1/security/sanitize-pdf", + parameters: { removeJavaScript: true }, + }, + { operation: "/api/v1/security/add-watermark", parameters: {} }, + { operation: "/api/v1/security/add-password", parameters: {} }, + { operation: "/api/v1/security/remove-password", parameters: {} }, + { operation: "/api/v1/general/merge-pdfs", parameters: {} }, + { operation: "/api/v1/misc/add-stamp", parameters: {} }, + { operation: "/api/v1/misc/add-page-numbers", parameters: {} }, +]; + +/** + * Turn an endpoint path into a display label: take the last segment, drop the + * "pdf"/"pdfs" filler words, and title-case the rest. + * "/api/v1/misc/compress-pdf" → "Compress"; "/api/v1/security/auto-redact" → + * "Auto Redact". Works for any operation, including ones loaded from the backend + * that aren't in the catalogue above. + */ +export function humanizeOperation(path: string): string { + const last = path.split("/").filter(Boolean).pop() ?? path; + const words = last.split("-").filter((w) => w !== "pdf" && w !== "pdfs"); + const base = (words.length > 0 ? words : [last]).join(" "); + return base.replace(/\b\w/g, (c) => c.toUpperCase()).trim(); +} diff --git a/frontend/portal/src/components/pipelines/stageAccent.ts b/frontend/portal/src/components/pipelines/stageAccent.ts deleted file mode 100644 index ee668a7af6..0000000000 --- a/frontend/portal/src/components/pipelines/stageAccent.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { StageKey } from "@portal/api/pipelines"; -import type { OpKind } from "@shared/data/ops"; - -/** - * Fixed accent per stage so the chip row reads the same across every pipeline. - * Each value is also a valid {@link import("@shared/components").ChipTone}, so - * it doubles as the chip tone wherever a stage colour is rendered. - */ -export type StageAccent = "green" | "blue" | "amber" | "red" | "purple"; - -export const STAGE_ACCENT: Record = { - ingest: "green", - validate: "blue", - modify: "amber", - secure: "red", - route: "purple", -}; - -/** Op-kind accent mirrors the stage palette; alerts share the route colour. */ -export const OP_KIND_ACCENT: Record = { - ingest: "green", - validate: "blue", - modify: "amber", - secure: "red", - store: "purple", - alert: "purple", -}; - -export const STAGE_COLOR_VAR: Record = { - green: "var(--color-green)", - blue: "var(--color-blue)", - amber: "var(--color-amber)", - red: "var(--color-red)", - purple: "var(--color-purple)", -}; diff --git a/frontend/portal/src/components/pipelines/storyFixtures.ts b/frontend/portal/src/components/pipelines/storyFixtures.ts deleted file mode 100644 index 1973edbae1..0000000000 --- a/frontend/portal/src/components/pipelines/storyFixtures.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { Pipeline, PromotedPipeline } from "@portal/api/pipelines"; - -/** Sample pipelines shared by the Pipelines component stories. */ - -export const HEALTHY_PIPELINE: Pipeline = { - id: "pl-invoice-ap", - name: "Invoice → AP", - blurb: "Invoice extraction → three-way match → Postgres", - status: "healthy", - source: "S3 bucket watch", - destination: "Database", - version: "v2.8.0", - regions: ["us-east-1", "eu-west-1", "ap-southeast-1"], - metrics: { - docs24h: 53120, - throughputPerMin: 41, - errorRate: 0.006, - p95LatencyMs: 358, - uptime: 0.9997, - }, - stages: [ - { key: "ingest", label: "Ingest", ops: ["Parse", "Classify", "Extract"] }, - { - key: "validate", - label: "Validate", - ops: ["Schema validate", "Confidence bounds"], - }, - { key: "modify", label: "Modify", ops: ["PDF → CSV"] }, - { - key: "secure", - label: "Secure", - ops: ["Redact PII", "Encryption at rest"], - }, - { key: "route", label: "Route / Store", ops: ["Primary store", "Notify"] }, - ], - golden: { passing: 42, total: 42, lastRun: "1h ago", threshold: 0.95 }, - drift: [], -}; - -export const DEGRADED_PIPELINE: Pipeline = { - ...HEALTHY_PIPELINE, - id: "pl-prior-auth", - name: "Prior Auth Router", - blurb: "Prior-authorization intake → medical-necessity gate → payer webhook", - status: "degraded", - source: "Inbound webhook", - destination: "Outbound webhook", - version: "v3.1.0", - regions: ["us-east-1"], - metrics: { - docs24h: 11204, - throughputPerMin: 9, - errorRate: 0.031, - p95LatencyMs: 740, - uptime: 0.9962, - }, - golden: { passing: 24, total: 28, lastRun: "47m ago", threshold: 0.9 }, - drift: [ - { - field: "procedure_codes", - note: "New CPT modifier suffix not seen in prior examples", - confidenceDelta: -0.07, - severity: "warning", - affectedDocs: 18, - }, - { - field: "payer", - note: "Two payers now emit a merged-entity name", - confidenceDelta: -0.03, - severity: "info", - affectedDocs: 6, - }, - ], -}; - -/** Sample watch-folder-promoted flows for the PromotedPipelines stories. */ -export const PROMOTED_PIPELINES: PromotedPipeline[] = [ - { - id: "pl-promo-statements", - name: "Bank Statement Normalizer", - sourceDocType: "Bank statement", - watchFolder: "~/StirlingWatch/statements-in", - status: "deployed", - promotedAt: "promoted 3d ago", - }, - { - id: "pl-promo-receipts", - name: "Receipt Splitter", - sourceDocType: "Expense receipt", - watchFolder: "~/StirlingWatch/receipts", - status: "staged", - promotedAt: "promoted 11h ago", - }, - { - id: "pl-promo-onboarding", - name: "New-Hire Packet Sorter", - sourceDocType: "Onboarding packet", - watchFolder: "\\\\hr-share\\NewHireScans", - status: "review", - promotedAt: "promoted 2h ago", - }, -]; diff --git a/frontend/portal/src/mocks/handlers/pipelines.ts b/frontend/portal/src/mocks/handlers/pipelines.ts index c947564652..6bce6db31a 100644 --- a/frontend/portal/src/mocks/handlers/pipelines.ts +++ b/frontend/portal/src/mocks/handlers/pipelines.ts @@ -1,20 +1,212 @@ import { http, HttpResponse, delay } from "msw"; -import type { Tier } from "@portal/contexts/TierContext"; -import { pipelinesFor } from "@portal/mocks/pipelines"; +import type { + PipelineKpi, + PipelineStatus, + PipelineView, + PipelinesOverviewResponse, + Policy, +} from "@portal/api/pipelines"; + +/** + * Stateful mock for the Pipelines surface so the portal works fully offline with + * mocks on. Mirrors the real backend shape (`/api/v1/policies`, PolicyController + + * PolicyOverviewService): the overview, create, edit, and delete mutate an + * in-memory store of real-shaped policies. With mocks OFF these calls fall through + * to the real backend instead. + * + * The user-facing Policies "catalogue" page also lives on `/api/v1/policies` (its + * own handlers, a different response shape). These handlers are registered first + * and DISCRIMINATE: anything that isn't a real-shaped pipeline (the catalogue + * page's bodies carry a `categoryId`; its ids aren't in this store) is passed + * through by returning nothing, so the catalogue handlers still serve it. That + * keeps both surfaces working in mock mode without either clobbering the other. + */ + +/** Display names for the seeded sources, so the overview resolves ids to names. */ +const SOURCE_NAMES: Record = { + "src-claims": "Claims intake", + "src-contracts": "Contracts drop", + "src-archive": "Archive reprocess", +}; + +interface StoredPolicy extends Policy { + id: string; +} + +function seedPipelines(): StoredPolicy[] { + return [ + { + id: "plc-redaction", + name: "Redaction sweep", + owner: "security@acme.com", + enabled: true, + trigger: { + type: "schedule", + options: { schedule: { type: "every", count: 6, unit: "HOURS" } }, + }, + sourceIds: ["src-claims"], + steps: [ + { + operation: "/api/v1/security/auto-redact", + parameters: { mode: "automatic", convertPDFToImage: true }, + }, + { operation: "/api/v1/security/sanitize-pdf", parameters: {} }, + ], + output: { type: "inline", options: {} }, + }, + { + id: "plc-archive", + name: "Archive compressor", + owner: "data-eng@acme.com", + enabled: true, + trigger: null, + sourceIds: ["src-contracts", "src-archive"], + steps: [{ operation: "/api/v1/misc/compress-pdf", parameters: {} }], + output: { type: "folder", options: { directory: "/data/archive-out" } }, + }, + { + id: "plc-onboarding", + name: "Onboarding OCR (paused)", + owner: "ops@acme.com", + enabled: false, + trigger: null, + sourceIds: [], + steps: [ + { operation: "/api/v1/misc/ocr-pdf", parameters: {} }, + { operation: "/api/v1/misc/flatten", parameters: {} }, + ], + output: { type: "inline", options: {} }, + }, + ]; +} + +let store: StoredPolicy[] = seedPipelines(); + +let idCounter = 0; +function nextId(): string { + idCounter += 1; + return `plc_${Date.now().toString(36)}_${idCounter}`; +} + +function deriveStatus(policy: StoredPolicy): PipelineStatus { + return policy.enabled ? "active" : "paused"; +} + +function toView(policy: StoredPolicy): PipelineView { + return { + id: policy.id, + name: policy.name, + enabled: policy.enabled, + status: deriveStatus(policy), + trigger: policy.trigger?.type ?? "manual", + sources: policy.sourceIds.map((id) => ({ + id, + name: SOURCE_NAMES[id] ?? id, + })), + steps: policy.steps.map((s) => s.operation), + output: policy.output?.type ?? "inline", + owner: policy.owner ?? "you@acme.com", + }; +} + +function buildKpis(): PipelineKpi[] { + const total = store.length; + const active = store.filter((p) => p.enabled).length; + return [ + { value: total, description: "pipelines" }, + { value: active, description: "running automatically" }, + { value: total - active, description: "paused" }, + ]; +} + +function buildOverview(): PipelinesOverviewResponse { + const pipelines = store + .map(toView) + .sort((a, b) => a.name.localeCompare(b.name)); + return { kpis: buildKpis(), pipelines }; +} export const pipelinesHandlers = [ - http.get("/v1/pipelines", async ({ request }) => { + http.get("/api/v1/policies/overview", async () => { await delay(120); - const url = new URL(request.url); - const tier = (url.searchParams.get("tier") ?? "pro") as Tier; - return HttpResponse.json(pipelinesFor(tier)); + return HttpResponse.json(buildOverview()); }), - // Accepts the promote-to-policy submit so the UI can resolve. The real - // backend would create a policy from the pipeline's stages; here it just - // acknowledges. See TODO(backend) on api/pipelines.ts promoteToPolicy. - http.post("/v1/pipelines/:id/promote-to-policy", async () => { + // Available triggers + their source-type compatibility. Registered before the + // ":id" handler so "triggers" isn't matched as a policy id. + http.get("/api/v1/policies/triggers", async () => { await delay(120); - return HttpResponse.json({ ok: true }); + return HttpResponse.json([ + { type: "schedule", requiresSource: false, supportedSourceTypes: [] }, + { + type: "folder-watch", + requiresSource: true, + supportedSourceTypes: ["folder"], + }, + ]); + }), + + // Run status: the mock completes runs immediately, so polling resolves at once. + http.get("/api/v1/policies/run/:runId", async ({ params }) => { + await delay(120); + return HttpResponse.json({ + runId: String(params.runId), + policyId: null, + status: "COMPLETED", + currentStep: 1, + stepCount: 1, + error: null, + errorCode: null, + createdAt: Date.now(), + }); + }), + + // Manual trigger: pretends to start one run and returns its id to poll. + http.post("/api/v1/policies/:id/trigger", async ({ params }) => { + if (!store.some((p) => p.id === params.id)) return undefined; + await delay(120); + return HttpResponse.json([`run_${Date.now().toString(36)}`]); + }), + + // Raw policy by id. Only our pipeline ids are served here; everything else falls + // through to the catalogue page's handler. + http.get("/api/v1/policies/:id", async ({ params }) => { + const policy = store.find((p) => p.id === params.id); + if (!policy) return undefined; + await delay(120); + return HttpResponse.json(policy); + }), + + // Create or update a pipeline. The catalogue page's bodies carry a `categoryId`; + // those are passed through so its own handler stores them. + http.post("/api/v1/policies", async ({ request }) => { + // Clone before reading: a non-pipeline body falls through to the catalogue + // page's handler, which needs to read the (still-unconsumed) body itself. + const incoming = (await request.clone().json()) as Policy & { + categoryId?: string; + }; + if ("categoryId" in incoming) return undefined; + await delay(120); + const existing = incoming.id + ? store.find((p) => p.id === incoming.id) + : undefined; + const id = existing?.id ?? nextId(); + const saved: StoredPolicy = { + ...incoming, + id, + owner: existing?.owner ?? "you@acme.com", + }; + store = existing + ? store.map((p) => (p.id === id ? saved : p)) + : [...store, saved]; + return HttpResponse.json(saved); + }), + + http.delete("/api/v1/policies/:id", async ({ params }) => { + const id = String(params.id); + if (!store.some((p) => p.id === id)) return undefined; + await delay(120); + store = store.filter((p) => p.id !== id); + return new HttpResponse(null, { status: 204 }); }), ]; diff --git a/frontend/portal/src/mocks/pipelines.ts b/frontend/portal/src/mocks/pipelines.ts deleted file mode 100644 index d39e7b7083..0000000000 --- a/frontend/portal/src/mocks/pipelines.ts +++ /dev/null @@ -1,415 +0,0 @@ -/** - * Pipelines fixtures and the types api/pipelines.ts shares with them. - * api/pipelines.ts imports the types; the MSW handlers in mocks/handlers/ - * serve the fixture data over the intercepted apiClient.local.json() calls. Components - * never reach into this module directly. - * - * Fixtures are tier-shaped: - * - free → empty (prompts "build your first pipeline") - * - pro → a small deployed fleet - * - enterprise → a larger fleet plus a shadow/comparative evals note - */ - -import type { Tier } from "@portal/contexts/TierContext"; - -/** A deployed pipeline's live health. */ -export type PipelineStatus = "healthy" | "degraded"; - -/** 24-hour rollup shown on the pipeline row. */ -export interface PipelineMetrics { - /** Docs processed in the trailing 24h. */ - docs24h: number; - /** Sustained throughput, docs/min. */ - throughputPerMin: number; - /** Error rate as a fraction (0.004 = 0.4%). */ - errorRate: number; - /** P95 stage-to-store latency, ms. */ - p95LatencyMs: number; - /** Uptime over the trailing 24h, as a fraction. */ - uptime: number; -} - -/** - * The five silent stages every pipeline passes a document through. The accent - * is fixed per stage so the chip row reads the same across every pipeline: - * Ingest=green, Validate=blue, Modify=amber, Secure=red, Route/Store=purple. - */ -export type StageKey = "ingest" | "validate" | "modify" | "secure" | "route"; - -export interface StageSummary { - key: StageKey; - label: string; - /** Op labels active in this stage for this pipeline. */ - ops: string[]; -} - -/** Golden-set validation rollup. */ -export interface GoldenSet { - passing: number; - total: number; - /** Last time the set was run. */ - lastRun: string; - /** - * Minimum pass rate (fraction) this pipeline must hold to be considered - * reliable. A pipeline below its own bound is amber/red at a glance — the - * bound is per-pipeline because a clause-risk pipeline tolerates less slack - * than a high-volume extraction one. - */ - threshold: number; -} - -/** A single field whose shape has drifted from the inferred schema. */ -export interface SchemaDrift { - field: string; - /** Human summary of what changed. */ - note: string; - /** Confidence delta since the last known-good shape (negative = worse). */ - confidenceDelta: number; - severity: "info" | "warning"; - /** Share of docs in the window that exhibited the drift. */ - affectedDocs: number; -} - -export interface Pipeline { - id: string; - name: string; - /** What the pipeline does, one line. */ - blurb: string; - status: PipelineStatus; - /** Source rail label (from SOURCE_OPTIONS). */ - source: string; - /** Destination rail label (from DESTINATION_OPTIONS). */ - destination: string; - /** Deployed version tag. */ - version: string; - /** Regions the pipeline runs in. */ - regions: string[]; - metrics: PipelineMetrics; - stages: StageSummary[]; - golden: GoldenSet; - drift: SchemaDrift[]; -} - -const STAGE_LABEL: Record = { - ingest: "Ingest", - validate: "Validate", - modify: "Modify", - secure: "Secure", - route: "Route / Store", -}; - -/** Build a five-stage summary from the per-stage op-label lists. */ -function stages( - ingest: string[], - validate: string[], - modify: string[], - secure: string[], - route: string[], -): StageSummary[] { - const byKey: Record = { - ingest, - validate, - modify, - secure, - route, - }; - return (Object.keys(byKey) as StageKey[]).map((key) => ({ - key, - label: STAGE_LABEL[key], - ops: byKey[key], - })); -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Tier fixtures */ -/* ──────────────────────────────────────────────────────────────────────── */ - -const COI_COMPLIANCE: Pipeline = { - id: "pl-coi", - name: "COI Compliance", - blurb: "Certificate-of-insurance intake → coverage-gap check → vault", - status: "healthy", - source: "Email intake", - destination: "Stirling vault", - version: "v3.4.1", - regions: ["us-east-1", "eu-west-1"], - metrics: { - docs24h: 28941, - throughputPerMin: 22, - errorRate: 0.004, - p95LatencyMs: 412, - uptime: 0.9998, - }, - stages: stages( - ["OCR", "Classify", "Extract"], - ["Schema validate", "Confidence bounds"], - ["Compress"], - ["Redact PII", "Encryption at rest"], - ["Primary store", "Processing manifest"], - ), - golden: { passing: 36, total: 36, lastRun: "2h ago", threshold: 0.95 }, - drift: [], -}; - -const PRIOR_AUTH: Pipeline = { - id: "pl-prior-auth", - name: "Prior Auth Router", - blurb: "Prior-authorization intake → medical-necessity gate → payer webhook", - status: "degraded", - source: "Inbound webhook", - destination: "Outbound webhook", - version: "v3.1.0", - regions: ["us-east-1"], - metrics: { - docs24h: 11204, - throughputPerMin: 9, - errorRate: 0.031, - p95LatencyMs: 740, - uptime: 0.9962, - }, - stages: stages( - ["OCR", "Classify", "Extract"], - ["Schema validate", "Counterparty match", "Confidence bounds"], - ["Convert"], - ["Redact PII", "PII/PHI enforcement", "Encryption at rest"], - ["Conditional routing", "Human review"], - ), - // Below its own 0.90 bound (24/28 ≈ 0.857) — the at-a-glance reliability miss. - golden: { passing: 24, total: 28, lastRun: "47m ago", threshold: 0.9 }, - drift: [ - { - field: "procedure_codes", - note: "New CPT modifier suffix not seen in prior examples", - confidenceDelta: -0.07, - severity: "warning", - affectedDocs: 18, - }, - { - field: "payer", - note: "Two payers now emit a merged-entity name", - confidenceDelta: -0.03, - severity: "info", - affectedDocs: 6, - }, - ], -}; - -const INVOICE_AP: Pipeline = { - id: "pl-invoice-ap", - name: "Invoice → AP", - blurb: "Invoice extraction → three-way match → Postgres", - status: "healthy", - source: "S3 bucket watch", - destination: "Database", - version: "v2.8.0", - regions: ["us-east-1", "eu-west-1", "ap-southeast-1"], - metrics: { - docs24h: 53120, - throughputPerMin: 41, - errorRate: 0.006, - p95LatencyMs: 358, - uptime: 0.9997, - }, - stages: stages( - ["Parse", "Classify", "Extract"], - ["Schema validate", "Confidence bounds"], - ["PDF → CSV"], - ["Redact PII", "Encryption at rest"], - ["Primary store", "Mirror to bucket", "Notify"], - ), - golden: { passing: 41, total: 42, lastRun: "1h ago", threshold: 0.95 }, - drift: [ - { - field: "tax", - note: "EU reverse-charge invoices omit a line-level tax field", - confidenceDelta: -0.02, - severity: "info", - affectedDocs: 4, - }, - ], -}; - -const CONTRACT_REVIEW: Pipeline = { - id: "pl-contract", - name: "Contract Review", - blurb: "Contract intake → clause-risk analysis → review queue", - status: "healthy", - source: "Upload API", - destination: "Another pipeline", - version: "v1.9.2", - regions: ["eu-west-1"], - metrics: { - docs24h: 4380, - throughputPerMin: 4, - errorRate: 0.009, - p95LatencyMs: 1280, - uptime: 0.9991, - }, - stages: stages( - ["OCR", "Classify"], - ["Contract analyzer", "Authenticity", "Confidence bounds"], - ["Document summarizer"], - ["Redact PII", "Confidentiality mark", "Signed outputs"], - ["Human review", "Flag"], - ), - golden: { passing: 31, total: 33, lastRun: "5h ago", threshold: 0.95 }, - drift: [], -}; - -const KYC_PROCESSOR: Pipeline = { - id: "pl-kyc", - name: "KYC Processor", - blurb: - "Identity-document intake → authenticity + sanctions → compliance archive", - status: "healthy", - source: "Scheduled import", - destination: "Compliance archive", - version: "v4.0.3", - regions: ["us-east-1", "eu-west-1", "ap-southeast-1"], - metrics: { - docs24h: 19870, - throughputPerMin: 15, - errorRate: 0.005, - p95LatencyMs: 503, - uptime: 0.9999, - }, - stages: stages( - ["OCR", "Classify", "Extract"], - ["Authenticity", "Tamper check", "Counterparty match"], - ["Convert"], - ["Field-aware redact", "Attribution watermark", "Encryption at rest"], - ["Compliance archive", "Processing manifest", "Notify"], - ), - golden: { passing: 52, total: 54, lastRun: "31m ago", threshold: 0.95 }, - drift: [ - { - field: "document_number", - note: "New passport series uses a 9-char alphanumeric format", - confidenceDelta: -0.04, - severity: "warning", - affectedDocs: 11, - }, - ], -}; - -/** - * Enterprise-only evals note. Surfaced as a banner: shadow + comparative eval - * runs gate every promotion, so the deployed fleet always trails a quietly - * running candidate. - */ -export interface EvalsNote { - /** Pipelines currently running a shadow eval against production traffic. */ - shadowCount: number; - /** Comparative (champion/challenger) runs awaiting sign-off. */ - comparativeCount: number; - detail: string; -} - -const ENTERPRISE_EVALS: EvalsNote = { - shadowCount: 2, - comparativeCount: 2, - detail: - "Prior Auth v3.2.0-rc and Invoice v2.9.0-rc are mirroring live traffic in shadow; KYC v4.1.0 is in a comparative run against v4.0.3. The Contract Review v2.0.0 candidate is blocked — it regressed 3 golden cases on clause-risk scoring, so the comparative run is held until the candidate is re-cut. No promotion happens until a candidate clears its golden set and the comparative delta stays inside bounds.", -}; - -/** - * A pipeline that began life as an Editor watch-folder flow and was promoted - * into the portal. These are the on-ramp from ad-hoc desktop automation to a - * governed, deployed pipeline — they keep a pointer back to the watch folder - * they grew out of so the lineage stays visible. - */ -export interface PromotedPipeline { - id: string; - name: string; - /** Doc type the originating watch-folder flow was built around. */ - sourceDocType: string; - /** The Editor watch folder this was promoted from. */ - watchFolder: string; - /** Where the promotion sits in its lifecycle. */ - status: PromotedStatus; - /** When the promotion landed. */ - promotedAt: string; -} - -/** - * A promoted flow is `deployed` once it runs in the portal, `staged` while it - * mirrors the watch folder without taking over, and `review` when it needs a - * human to confirm the flow before it goes live. - */ -export type PromotedStatus = "deployed" | "staged" | "review"; - -export interface PipelinesResponse { - pipelines: Pipeline[]; - /** Present for enterprise only. */ - evals: EvalsNote | null; - /** Flows promoted up from Editor watch folders. Empty on free. */ - promoted: PromotedPipeline[]; -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Promoted-from-Editor fixtures */ -/* ──────────────────────────────────────────────────────────────────────── */ - -const PROMOTED_PRO: PromotedPipeline[] = [ - { - id: "pl-promo-statements", - name: "Bank Statement Normalizer", - sourceDocType: "Bank statement", - watchFolder: "~/StirlingWatch/statements-in", - status: "deployed", - promotedAt: "promoted 3d ago", - }, - { - id: "pl-promo-receipts", - name: "Receipt Splitter", - sourceDocType: "Expense receipt", - watchFolder: "~/StirlingWatch/receipts", - status: "staged", - promotedAt: "promoted 11h ago", - }, -]; - -const PROMOTED_ENTERPRISE: PromotedPipeline[] = [ - ...PROMOTED_PRO, - { - id: "pl-promo-claims", - name: "Claims Intake Splitter", - sourceDocType: "Insurance claim", - watchFolder: "\\\\fileserver\\ClaimsDropbox", - status: "deployed", - promotedAt: "promoted 6d ago", - }, - { - id: "pl-promo-onboarding", - name: "New-Hire Packet Sorter", - sourceDocType: "Onboarding packet", - watchFolder: "\\\\hr-share\\NewHireScans", - status: "review", - promotedAt: "promoted 2h ago", - }, -]; - -export function pipelinesFor(tier: Tier): PipelinesResponse { - if (tier === "free") { - return { pipelines: [], evals: null, promoted: [] }; - } - if (tier === "enterprise") { - return { - pipelines: [ - INVOICE_AP, - COI_COMPLIANCE, - PRIOR_AUTH, - KYC_PROCESSOR, - CONTRACT_REVIEW, - ], - evals: ENTERPRISE_EVALS, - promoted: PROMOTED_ENTERPRISE, - }; - } - // pro - return { - pipelines: [COI_COMPLIANCE, INVOICE_AP, PRIOR_AUTH], - evals: null, - promoted: PROMOTED_PRO, - }; -} diff --git a/frontend/portal/src/views/Pipelines.css b/frontend/portal/src/views/Pipelines.css index abae938dd2..c61f6bcafe 100644 --- a/frontend/portal/src/views/Pipelines.css +++ b/frontend/portal/src/views/Pipelines.css @@ -8,7 +8,7 @@ } /* Header */ -.portal-pipelines__header { +.portal-pipelines__head { display: flex; align-items: flex-start; justify-content: space-between; @@ -30,574 +30,280 @@ max-width: 46rem; } -/* Fleet summary chips */ -.portal-pipelines__fleet { +/* Table cells */ +.portal-pipelines__name-cell { display: flex; align-items: center; - gap: 0.5rem; -} - -.portal-pipelines__fleet-count { - font-size: 0.75rem; - color: var(--color-text-4); -} - -/* List */ -.portal-pipelines__list { - display: flex; - flex-direction: column; - gap: 0.875rem; -} - -.portal-pipelines__card { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.portal-pipelines__card-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 0.75rem; -} - -.portal-pipelines__card-title { - margin: 0; - font-size: 1.0625rem; - font-weight: 600; - color: var(--color-text-1); -} - -.portal-pipelines__card-blurb { - margin: 0.125rem 0 0; - font-size: 0.8125rem; - color: var(--color-text-3); -} - -/* Source → stages → destination rail */ -.portal-pipelines__card-rail { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; -} - -.portal-pipelines__rail-chip { - font-size: 0.6875rem; - font-weight: 500; - color: var(--color-text-3); - background: var(--color-bg-subtle); - border: 1px solid var(--color-border-light); - border-radius: var(--radius-pill); - padding: 0.1875rem 0.5rem; -} - -.portal-pipelines__rail-arrow { - color: var(--color-text-5); - font-size: 0.75rem; -} - -.portal-pipelines__stage-dots { - display: inline-flex; - align-items: center; - gap: 0.25rem; -} - -.portal-pipelines__stage-dot { - width: 0.5rem; - height: 0.5rem; - border-radius: 50%; -} - -/* Metric strip on the card */ -.portal-pipelines__metrics { - display: grid; - grid-template-columns: repeat(5, 1fr); - gap: 0.5rem; - padding: 0.625rem 0; - border-top: 1px solid var(--color-border-light); - border-bottom: 1px solid var(--color-border-light); -} - -@media (max-width: 50rem) { - .portal-pipelines__metrics { - grid-template-columns: repeat(2, 1fr); - } -} - -.portal-pipelines__card-foot { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.75rem; - font-size: 0.6875rem; - color: var(--color-text-4); -} - -.portal-pipelines__card-version { - font-family: var(--font-mono); -} - -.portal-pipelines__card-golden { - color: var(--color-text-3); -} - -.portal-pipelines__card-drift { - color: var(--color-amber-dark); -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Detail drawer */ -/* ──────────────────────────────────────────────────────────────────────── */ - -.portal-pipelines__detail { - display: flex; - flex-direction: column; - gap: 1.5rem; -} - -.portal-pipelines__detail-metrics { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 0.625rem; - padding: 0.875rem; - background: var(--color-bg-subtle); - border: 1px solid var(--color-border-light); - border-radius: var(--radius-md); -} - -.portal-pipelines__detail-section { - display: flex; - flex-direction: column; - gap: 0.625rem; -} - -.portal-pipelines__detail-h { - margin: 0; - font-size: 0.9375rem; - font-weight: 600; - color: var(--color-text-1); -} - -.portal-pipelines__detail-sub { - margin: -0.375rem 0 0; - font-size: 0.75rem; - color: var(--color-text-4); -} - -/* The five silent stages */ -.portal-pipelines__stages { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.portal-pipelines__stage { - padding: 0.625rem 0.75rem; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); -} - -.portal-pipelines__stage-head { - display: flex; - align-items: center; - gap: 0.4375rem; - margin-bottom: 0.5rem; -} - -.portal-pipelines__stage-pip { - width: 0.5rem; - height: 0.5rem; - border-radius: 50%; -} - -.portal-pipelines__stage-name { - font-size: 0.8125rem; - font-weight: 600; - color: var(--color-text-2); -} - -.portal-pipelines__stage-chips { - display: flex; - flex-wrap: wrap; - gap: 0.375rem; -} - -.portal-pipelines__stage-empty { - font-size: 0.75rem; - color: var(--color-text-5); - font-style: italic; -} - -/* Golden set */ -.portal-pipelines__golden { - display: flex; - flex-direction: column; - gap: 0.5rem; - padding: 0.75rem; - background: var(--color-bg-subtle); - border: 1px solid var(--color-border-light); - border-radius: var(--radius-md); -} - -.portal-pipelines__golden-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; -} - -.portal-pipelines__golden-when { - font-size: 0.6875rem; - color: var(--color-text-4); -} - -/* Schema drift */ -.portal-pipelines__drift-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.portal-pipelines__drift { - display: grid; - grid-template-columns: auto 1fr auto; - align-items: center; gap: 0.625rem; - padding: 0.625rem 0.75rem; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); -} - -.portal-pipelines__drift-dot { - width: 0.5rem; - height: 0.5rem; - border-radius: 50%; -} - -.portal-pipelines__drift-text { - display: flex; - flex-direction: column; - gap: 0.125rem; min-width: 0; } -.portal-pipelines__drift-field { - font-size: 0.75rem; - font-weight: 600; - color: var(--color-text-1); - font-family: var(--font-mono); -} - -.portal-pipelines__drift-note { - font-size: 0.75rem; - color: var(--color-text-4); -} - -.portal-pipelines__drift-meta { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 0.125rem; - font-size: 0.6875rem; - color: var(--color-text-4); - font-variant-numeric: tabular-nums; - white-space: nowrap; -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Composer modal */ -/* ──────────────────────────────────────────────────────────────────────── */ - -.portal-pipelines__composer { - min-height: 22rem; -} - -.portal-pipelines__composer-body { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.portal-pipelines__composer-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 0.625rem; -} - -@media (max-width: 44rem) { - .portal-pipelines__composer-grid { - grid-template-columns: 1fr; - } -} - -.portal-pipelines__option { +.portal-pipelines__name-text { display: flex; flex-direction: column; gap: 0.25rem; - padding: 0.75rem; - text-align: left; - background: var(--color-surface); - border: 1px solid var(--color-border); + min-width: 0; +} + +.portal-pipelines__name-text strong { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-pipelines__pipe-dot { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.875rem; + height: 1.875rem; + flex-shrink: 0; border-radius: var(--radius-md); - cursor: pointer; - transition: - border-color var(--motion-fast), - background var(--motion-fast); -} - -.portal-pipelines__option:hover { - border-color: var(--color-border-hover); - background: var(--color-bg-hover); -} - -.portal-pipelines__option.is-selected { - border-color: var(--color-blue); + font-size: 0.875rem; background: var(--color-blue-light); -} - -.portal-pipelines__option-label { - font-size: 0.8125rem; - font-weight: 600; - color: var(--color-text-1); -} - -.portal-pipelines__option-desc { - font-size: 0.6875rem; - color: var(--color-text-4); - line-height: 1.4; -} - -/* Op chain summary */ -.portal-pipelines__chain { - display: flex; - flex-direction: column; - gap: 0.5rem; - padding: 0.75rem; - background: var(--color-bg-subtle); - border: 1px solid var(--color-border-light); - border-radius: var(--radius-md); -} - -.portal-pipelines__chain-label, -.portal-pipelines__agents-label { - font-size: 0.75rem; - font-weight: 600; - color: var(--color-text-3); - text-transform: uppercase; - letter-spacing: 0.03em; -} - -.portal-pipelines__chain-chips, -.portal-pipelines__agents-row { - display: flex; - flex-wrap: wrap; - gap: 0.375rem; -} - -.portal-pipelines__chain-empty { - font-size: 0.75rem; - color: var(--color-text-5); - font-style: italic; -} - -.portal-pipelines__agents { - display: flex; - flex-direction: column; - gap: 0.4375rem; -} - -/* Op library picker */ -.portal-pipelines__library { - display: flex; - flex-direction: column; - gap: 0.875rem; - max-height: 19rem; - overflow-y: auto; - padding: 0.75rem; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); -} - -.portal-pipelines__library-group { - display: flex; - flex-direction: column; - gap: 0.4375rem; -} - -.portal-pipelines__library-head { - display: flex; - align-items: center; - gap: 0.375rem; - font-size: 0.75rem; - font-weight: 600; - color: var(--color-text-2); -} - -.portal-pipelines__library-pip { - width: 0.4375rem; - height: 0.4375rem; - border-radius: 50%; -} - -.portal-pipelines__library-chips { - display: flex; - flex-wrap: wrap; - gap: 0.375rem; -} - -/* Routing alerts */ -.portal-pipelines__alerts { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.portal-pipelines__alert { - display: grid; - grid-template-columns: auto 1fr; - align-items: start; - gap: 0.625rem; - padding: 0.625rem 0.75rem; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - cursor: pointer; -} - -.portal-pipelines__alert input { - margin-top: 0.1875rem; - accent-color: var(--color-blue); -} - -.portal-pipelines__alert > span { - display: flex; - flex-direction: column; - gap: 0.0625rem; -} - -.portal-pipelines__alert strong { - font-size: 0.8125rem; - font-weight: 600; - color: var(--color-text-1); -} - -.portal-pipelines__alert span span { - font-size: 0.6875rem; - color: var(--color-text-4); -} - -/* Composer step indicator in the footer */ -.portal-pipelines__composer-steps { - display: flex; - align-items: center; - gap: 0.625rem; - margin-right: auto; -} - -.portal-pipelines__composer-step { - font-size: 0.6875rem; - font-weight: 500; - color: var(--color-text-5); -} - -.portal-pipelines__composer-step.is-active { color: var(--color-blue); - font-weight: 600; } -.portal-pipelines__composer-step.is-done { - color: var(--color-green-dark); +.portal-pipelines__muted { + font-size: 0.8125rem; + color: var(--color-text-4); + margin: 0; } -/* ──────────────────────────────────────────────────────────────────────── */ -/* Roster + promoted sections */ -/* ──────────────────────────────────────────────────────────────────────── */ +.portal-pipelines__caret { + display: inline-block; + color: var(--color-text-5); + transition: transform var(--motion-fast); +} -.portal-pipelines__section { +.portal-pipelines__caret.is-open { + transform: rotate(90deg); + color: var(--color-blue); +} + +/* Expanded detail panel */ +.portal-pipelines__expanded { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + padding: 1.25rem; + animation: portal-pipelines-reveal var(--motion-fast) ease-out; +} + +@keyframes portal-pipelines-reveal { + from { + opacity: 0; + transform: translateY(-0.25rem); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.portal-pipelines__expanded-head { display: flex; - flex-direction: column; - gap: 0.625rem; + align-items: center; + gap: 0.75rem; + padding-bottom: 0.875rem; + margin-bottom: 1rem; + border-bottom: 1px solid var(--color-border-light); } -.portal-pipelines__section-h { +.portal-pipelines__expanded-title { margin: 0; font-size: 1rem; font-weight: 600; color: var(--color-text-1); } -.portal-pipelines__section-sub { - margin: -0.25rem 0 0.25rem; +.portal-pipelines__expanded-sub { font-size: 0.75rem; color: var(--color-text-4); - max-width: 46rem; } -/* Golden-set roster table */ -.portal-pipelines__roster-name, -.portal-pipelines__promoted-name { - display: flex; - flex-direction: column; - gap: 0.125rem; - min-width: 0; +.portal-pipelines__expanded-close { + margin-left: auto; + width: 1.75rem; + height: 1.75rem; + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text-4); + font-size: 1.125rem; + line-height: 1; + cursor: pointer; + transition: + background var(--motion-fast), + color var(--motion-fast); } -.portal-pipelines__roster-name strong, -.portal-pipelines__promoted-name strong { - font-size: 0.8125rem; - font-weight: 600; +.portal-pipelines__expanded-close:hover { + background: var(--color-bg-hover); color: var(--color-text-1); } -.portal-pipelines__roster-route, -.portal-pipelines__promoted-when { - font-size: 0.6875rem; - color: var(--color-text-4); +/* Detail body */ +.portal-pipelines__detail { + display: flex; + flex-direction: column; + gap: 1.125rem; } -.portal-pipelines__roster-golden { +.portal-pipelines__detail-section { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.portal-pipelines__detail-heading { + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-5); + font-weight: 600; +} + +.portal-pipelines__chips { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.portal-pipelines__detail-actions { + display: flex; + gap: 0.5rem; + padding-top: 0.875rem; + margin-top: 1rem; + border-top: 1px solid var(--color-border-light); +} + +/* Table skeleton */ +.portal-pipelines__table-skeleton { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Composer */ +/* ──────────────────────────────────────────────────────────────────────── */ + +.portal-pipelines__composer { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.portal-pipelines__composer-section { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.portal-pipelines__composer-footer { + display: flex; + justify-content: space-between; + gap: 0.5rem; + width: 100%; +} + +.portal-pipelines__source-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + max-height: 12rem; + overflow-y: auto; + padding: 0.625rem; + background: var(--color-bg-subtle); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); +} + +/* Operation chain */ +.portal-pipelines__chain { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.portal-pipelines__chain-row { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.4375rem 0.625rem; + background: var(--color-bg-subtle); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); +} + +.portal-pipelines__chain-index { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + flex-shrink: 0; + border-radius: 50%; + font-size: 0.6875rem; + font-weight: 600; + background: var(--color-blue-light); + color: var(--color-blue); +} + +.portal-pipelines__chain-op { + flex: 1; + font-size: 0.8125rem; + color: var(--color-text-1); +} + +.portal-pipelines__chain-actions { + display: flex; + gap: 0.25rem; +} + +.portal-pipelines__chain-actions button { + width: 1.5rem; + height: 1.5rem; + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text-3); + font-size: 0.8125rem; + line-height: 1; + cursor: pointer; + transition: + background var(--motion-fast), + color var(--motion-fast); +} + +.portal-pipelines__chain-actions button:hover:not(:disabled) { + background: var(--color-bg-hover); + color: var(--color-text-1); +} + +.portal-pipelines__chain-actions button:disabled { + opacity: 0.4; + cursor: default; +} + +.portal-pipelines__op-palette { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +/* Schedule row */ +.portal-pipelines__schedule { display: flex; align-items: center; gap: 0.5rem; } -.portal-pipelines__roster-rate { - font-size: 0.6875rem; - color: var(--color-text-4); - font-variant-numeric: tabular-nums; -} - -.portal-pipelines__roster-num, -.portal-pipelines__roster-version { - font-variant-numeric: tabular-nums; - color: var(--color-text-2); -} - -.portal-pipelines__roster-version { - font-family: var(--font-mono); - font-size: 0.75rem; -} - -/* Promoted-from-Editor table */ -.portal-pipelines__promoted-muted { - color: var(--color-text-3); - font-size: 0.8125rem; -} - -.portal-pipelines__promoted-folder { - font-family: var(--font-mono); - font-size: 0.75rem; - color: var(--color-text-3); - background: var(--color-bg-subtle); - border: 1px solid var(--color-border-light); - border-radius: var(--radius-sm); - padding: 0.0625rem 0.375rem; +.portal-pipelines__schedule-count { + width: 5rem; } diff --git a/frontend/portal/src/views/Pipelines.test.tsx b/frontend/portal/src/views/Pipelines.test.tsx new file mode 100644 index 0000000000..afcad9be07 --- /dev/null +++ b/frontend/portal/src/views/Pipelines.test.tsx @@ -0,0 +1,245 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { HttpError } from "@portal/api/http"; +import type { PipelinesOverviewResponse, Policy } from "@portal/api/pipelines"; +import type { SourcesResponse } from "@portal/api/sources"; +import { Pipelines } from "@portal/views/Pipelines"; + +// Deterministic i18n: keys returned verbatim, so assertions are stable without +// the async TOML backend. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +const fetchPipelines = vi.fn(); +const fetchPipeline = vi.fn(); +const savePipeline = vi.fn(); +const deletePipeline = vi.fn(); +const fetchTriggers = vi.fn(); +const triggerPipeline = vi.fn(); +const fetchRun = vi.fn(); +vi.mock("@portal/api/pipelines", () => ({ + fetchPipelines: () => fetchPipelines(), + fetchPipeline: (id: string) => fetchPipeline(id), + savePipeline: (policy: unknown) => savePipeline(policy), + deletePipeline: (id: string) => deletePipeline(id), + fetchTriggers: () => fetchTriggers(), + triggerPipeline: (id: string) => triggerPipeline(id), + fetchRun: (runId: string) => fetchRun(runId), +})); + +const fetchSources = vi.fn(); +vi.mock("@portal/api/sources", () => ({ + fetchSources: () => fetchSources(), +})); + +const RESPONSE: PipelinesOverviewResponse = { + kpis: [ + { value: 2, description: "" }, + { value: 2, description: "" }, + { value: 0, description: "" }, + ], + pipelines: [ + { + id: "plc-redaction", + name: "Redaction sweep", + enabled: true, + status: "active", + trigger: "schedule", + sources: [{ id: "src-claims", name: "Claims intake" }], + steps: ["/api/v1/security/auto-redact"], + output: "inline", + owner: "security@acme.com", + }, + { + id: "plc-archive", + name: "Archive compressor", + enabled: true, + status: "active", + trigger: "manual", + sources: [], + steps: ["/api/v1/misc/compress-pdf"], + output: "folder", + owner: "data@acme.com", + }, + ], +}; + +const RAW_REDACTION: Policy = { + id: "plc-redaction", + name: "Redaction sweep", + enabled: true, + trigger: { + type: "schedule", + options: { schedule: { type: "every", count: 6, unit: "HOURS" } }, + }, + sourceIds: ["src-claims"], + steps: [{ operation: "/api/v1/security/auto-redact", parameters: {} }], + output: { type: "inline", options: {} }, +}; + +const SOURCES: SourcesResponse = { + kpis: [], + sources: [ + { + id: "src-claims", + name: "Claims intake", + type: "folder", + status: "active", + referenceCount: 1, + referencingPolicies: [], + config: [], + docsTotal: null, + }, + ], +}; + +function renderView() { + return render( + + + , + ); +} + +describe("Pipelines view", () => { + beforeEach(() => { + fetchPipelines.mockReset(); + fetchPipeline.mockReset(); + savePipeline.mockReset(); + deletePipeline.mockReset(); + fetchSources.mockReset(); + fetchTriggers.mockReset(); + triggerPipeline.mockReset(); + fetchRun.mockReset(); + // The composer loads the trigger registry on open; default to none. + fetchTriggers.mockResolvedValue([]); + }); + + it("surfaces the inline error message when a delete fails", async () => { + fetchPipelines.mockResolvedValue(RESPONSE); + deletePipeline.mockRejectedValue( + new HttpError(500, "Server Error", { + detail: "Could not delete pipeline", + }), + ); + + renderView(); + + fireEvent.click(await screen.findByText("Redaction sweep")); + fireEvent.click(await screen.findByText("pipelines.detail.delete")); + fireEvent.click(await screen.findByText("pipelines.delete.confirm")); + + await waitFor(() => { + expect(deletePipeline).toHaveBeenCalledWith("plc-redaction"); + }); + expect( + await screen.findByText("Could not delete pipeline"), + ).toBeInTheDocument(); + }); + + it("pauses a pipeline by re-saving it with enabled flipped off", async () => { + fetchPipelines.mockResolvedValue(RESPONSE); + fetchPipeline.mockResolvedValue(RAW_REDACTION); + savePipeline.mockResolvedValue({}); + + renderView(); + + fireEvent.click(await screen.findByText("Redaction sweep")); + fireEvent.click(await screen.findByText("pipelines.detail.pause")); + + await waitFor(() => { + expect(savePipeline).toHaveBeenCalledTimes(1); + }); + expect(fetchPipeline).toHaveBeenCalledWith("plc-redaction"); + expect(savePipeline).toHaveBeenCalledWith( + expect.objectContaining({ id: "plc-redaction", enabled: false }), + ); + }); + + it("runs a pipeline now and reports success inline", async () => { + fetchPipelines.mockResolvedValue(RESPONSE); + triggerPipeline.mockResolvedValue(["run-1"]); + fetchRun.mockResolvedValue({ + runId: "run-1", + policyId: "plc-redaction", + status: "COMPLETED", + currentStep: 1, + stepCount: 1, + error: null, + errorCode: null, + createdAt: 0, + }); + + renderView(); + + fireEvent.click(await screen.findByText("Redaction sweep")); + fireEvent.click(await screen.findByText("pipelines.detail.run")); + + await waitFor(() => { + expect(triggerPipeline).toHaveBeenCalledWith("plc-redaction"); + }); + expect( + await screen.findByText("pipelines.run.completed"), + ).toBeInTheDocument(); + }); + + it("surfaces an execution failure from a manual run", async () => { + fetchPipelines.mockResolvedValue(RESPONSE); + triggerPipeline.mockResolvedValue(["run-1"]); + fetchRun.mockResolvedValue({ + runId: "run-1", + policyId: "plc-redaction", + status: "FAILED", + currentStep: 1, + stepCount: 1, + error: "step 1 blew up", + errorCode: null, + createdAt: 0, + }); + + renderView(); + + fireEvent.click(await screen.findByText("Redaction sweep")); + fireEvent.click(await screen.findByText("pipelines.detail.run")); + + expect(await screen.findByText("pipelines.run.failed")).toBeInTheDocument(); + }); + + it("creates a pipeline with the chosen name and chained operation", async () => { + fetchPipelines.mockResolvedValue(RESPONSE); + fetchSources.mockResolvedValue(SOURCES); + savePipeline.mockResolvedValue({}); + + renderView(); + + // Wait for the table so the initial fetch has settled, then open the composer. + await screen.findByText("Redaction sweep"); + fireEvent.click(screen.getByText("pipelines.actions.newPipeline")); + + fireEvent.change(await screen.findByRole("textbox"), { + target: { value: "Nightly compress" }, + }); + // Operation palette chip labels are derived from the endpoint path. + fireEvent.click(await screen.findByText("+ Compress")); + fireEvent.click(screen.getByText("pipelines.composer.create")); + + await waitFor(() => { + expect(savePipeline).toHaveBeenCalledTimes(1); + }); + expect(savePipeline).toHaveBeenCalledWith( + expect.objectContaining({ + name: "Nightly compress", + trigger: null, + output: expect.objectContaining({ type: "inline" }), + steps: [ + expect.objectContaining({ operation: "/api/v1/misc/compress-pdf" }), + ], + }), + ); + }); +}); diff --git a/frontend/portal/src/views/Pipelines.tsx b/frontend/portal/src/views/Pipelines.tsx index 05349e2a9b..df003f6915 100644 --- a/frontend/portal/src/views/Pipelines.tsx +++ b/frontend/portal/src/views/Pipelines.tsx @@ -1,162 +1,205 @@ -import { useMemo, useState } from "react"; +import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import { Banner, Button, - Drawer, EmptyState, - StatusBadge, + Modal, + Skeleton, } from "@shared/components"; -import { useTier } from "@portal/contexts/TierContext"; import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; +import { errorMessage } from "@portal/api/http"; import { + deletePipeline, + fetchPipeline, fetchPipelines, - type Pipeline, - type PipelinesResponse, + savePipeline, + type PipelinesOverviewResponse, + type PipelineView, + type Policy, } from "@portal/api/pipelines"; -import { DeployedPipelinesTable } from "@portal/components/pipelines/DeployedPipelinesTable"; -import { PipelineCard } from "@portal/components/pipelines/PipelineCard"; +import { KpiStrip } from "@portal/components/pipelines/KpiStrip"; +import { PipelinesTable } from "@portal/components/pipelines/PipelinesTable"; +import { PipelineDetailCard } from "@portal/components/pipelines/PipelineDetailCard"; import { PipelineComposer } from "@portal/components/pipelines/PipelineComposer"; -import { PipelineDetail } from "@portal/components/pipelines/PipelineDetail"; -import { PipelineListSkeleton } from "@portal/components/pipelines/PipelineListSkeleton"; -import { PromotedPipelines } from "@portal/components/pipelines/PromotedPipelines"; import "@portal/views/Pipelines.css"; export function Pipelines() { const { t } = useTranslation(); - const { tier } = useTier(); - const state = useAsync(() => fetchPipelines(tier), [tier]); - const { data } = state; - const { isLoading } = useSectionFlags(state); + // Refetch after every mutation by bumping this counter, so the table reflects + // the backend (mirrors the Sources view). + const [version, setVersion] = useState(0); + const state = useAsync( + () => fetchPipelines(), + [version], + ); + const { data, loading } = state; + const { isLoading, isEmpty } = useSectionFlags(state); + const refetch = useCallback(() => setVersion((v) => v + 1), []); + const [expandedId, setExpandedId] = useState(null); const [composerOpen, setComposerOpen] = useState(false); - const [selected, setSelected] = useState(null); + const [editing, setEditing] = useState(null); + const [mutating, setMutating] = useState(false); + const [pageError, setPageError] = useState(null); + const [pendingDelete, setPendingDelete] = useState(null); + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); const pipelines = data?.pipelines ?? []; - const evals = data?.evals ?? null; - const promoted = data?.promoted ?? []; - const isEmpty = !isLoading && pipelines.length === 0; + const expanded = pipelines.find((p) => p.id === expandedId) ?? null; - const fleetHealthy = useMemo( - () => pipelines.filter((p) => p.status === "healthy").length, - [pipelines], - ); + function openCreate() { + setEditing(null); + setComposerOpen(true); + } + + // Editing needs the raw policy (steps, trigger, source ids), which the overview + // rows don't carry, so fetch it before opening the composer prefilled. + async function openEdit(pipeline: PipelineView) { + if (mutating) return; + setPageError(null); + setMutating(true); + try { + setEditing(await fetchPipeline(pipeline.id)); + setComposerOpen(true); + } catch (e) { + setPageError(errorMessage(e)); + } finally { + setMutating(false); + } + } + + // Pause/resume: re-save the policy with enabled flipped (the backend has no + // dedicated endpoint; every mutation routes through POST /policies). Fetch the + // raw record first so the full config round-trips intact. + async function togglePause(pipeline: PipelineView) { + if (mutating) return; + setPageError(null); + setMutating(true); + try { + const raw = await fetchPipeline(pipeline.id); + await savePipeline({ ...raw, enabled: !raw.enabled }); + refetch(); + } catch (e) { + setPageError(errorMessage(e)); + } finally { + setMutating(false); + } + } + + function requestDelete(pipeline: PipelineView) { + setDeleteError(null); + setPendingDelete(pipeline); + } + + async function confirmDelete() { + if (!pendingDelete || deleting) return; + setDeleting(true); + setDeleteError(null); + try { + await deletePipeline(pendingDelete.id); + setPendingDelete(null); + setExpandedId(null); + refetch(); + } catch (e) { + setDeleteError(errorMessage(e)); + } finally { + setDeleting(false); + } + } return (
    -
    +

    {t("pipelines.title")}

    {t("pipelines.subtitle")}

    -
    - {!isLoading && pipelines.length > 0 && ( -
    - - {t("pipelines.fleet.healthy", { count: fleetHealthy })} - - {fleetHealthy < pipelines.length && ( - - {t("pipelines.fleet.degraded", { - count: pipelines.length - fleetHealthy, - })} - - )} - - {t("pipelines.fleet.deployed", { count: pipelines.length })} - + {pageError && } + + + + {isLoading && ( +
    + {Array.from({ length: 5 }).map((_, i) => ( + + ))}
    )} - {tier === "enterprise" && evals && ( - - {t("pipelines.evals.body", { - count: evals.shadowCount, - comparativeCount: evals.comparativeCount, - detail: evals.detail, - })} - - )} - - {isLoading && } - {isEmpty && ( setComposerOpen(true)}> - {t("pipelines.empty.action")} - + } /> )} - {pipelines.length > 0 && ( -
    -

    - {t("pipelines.reliability.heading")} -

    -

    - {t("pipelines.reliability.description")} -

    - -
    + {!isLoading && !isEmpty && pipelines.length > 0 && ( + + setExpandedId((cur) => (cur === p.id ? null : p.id)) + } + /> )} - {pipelines.length > 0 && ( -
    - {pipelines.map((p) => ( - - ))} -
    + {expanded && ( + setExpandedId(null)} + onEdit={openEdit} + onTogglePause={togglePause} + onDelete={requestDelete} + busy={mutating} + /> )} - {promoted.length > 0 && ( -
    -

    - {t("pipelines.promoted.heading")} -

    -

    - {t("pipelines.promoted.description")} -

    - -
    - )} - - setSelected(null)} - width="lg" - title={selected?.name} - subtitle={ - selected - ? t("pipelines.detail.subtitle", { - version: selected.version, - source: selected.source, - destination: selected.destination, - }) - : undefined - } - > - {selected && } - - setComposerOpen(false)} + onSaved={refetch} /> + + !deleting && setPendingDelete(null)} + width="sm" + title={t("pipelines.delete.title")} + footer={ +
    + + +
    + } + > +

    {t("pipelines.delete.body", { name: pendingDelete?.name ?? "" })}

    + {deleteError && } +
    ); }