Support tool selection in Pipelines page in Portal (#6905)

# Description of Changes

Redesigned the Portal Pipelines page so pipelines are created and edited
on their own dedicated builder page, replacing the previous modal
composer and inline detail card.

## Screenshots

### Pipelines list
The redesigned list with summary KPIs; each row opens that pipeline's
own page.

![Pipelines
list](https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/pr-6905-screenshots/screenshots/01-pipelines-list.png)

### Pipeline builder
The dedicated create page: pipeline settings (sources, trigger, output)
above, operations and per-tool settings below.

![Pipeline
builder](https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/pr-6905-screenshots/screenshots/02-builder-new.png)

### Tool picker
Type-to-filter, category-grouped picker for adding an operation to the
pipeline.

![Tool
picker](https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/pr-6905-screenshots/screenshots/03-tool-picker.png)

### Editing a pipeline
An existing pipeline in the builder: reorderable steps, per-tool
settings, and run/delete actions.

![Editing a
pipeline](https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/pr-6905-screenshots/screenshots/04-builder-edit.png)
This commit is contained in:
James Brunton
2026-07-08 15:23:04 +00:00
committed by GitHub
parent 1759e0bdd5
commit d6061eb0aa
22 changed files with 2297 additions and 1282 deletions
@@ -7237,18 +7237,39 @@ title = "Pipelines"
[portal.pipelines.actions]
newPipeline = "New pipeline"
[portal.pipelines.builder]
addStep = "Add tool"
back = "Back to pipelines"
discard = "Discard changes"
enabled = "Enabled"
keepEditing = "Keep editing"
needsUpload = "Needs an uploaded file"
noToolMatches = "No tools match your search."
pipelineSettings = "Pipeline settings"
searchTools = "Search tools"
selectToolBody = "Add a tool to build your pipeline."
selectToolTitle = "No tools yet"
toolSettings = "Tool settings"
unknownStep = "Unrecognized operation, kept as-is."
unsavedBody = "You have unsaved changes. Save them before leaving, or discard them?"
unsavedTitle = "Unsaved changes"
uploadUnsupported = "Uploaded files aren't supported in pipelines yet, so these steps can't be saved: {{tools}}."
usesDefaults = "Runs with default settings"
[portal.pipelines.composer]
addTool = "Add tool"
cancel = "Cancel"
chainEmpty = "Add operations from the palette below."
chainEmpty = "Add a tool to start building your pipeline."
create = "Create pipeline"
directory = "Output folder"
directoryHelp = "Absolute path on the server. Must be within the configured allowed folders."
editTitle = "Edit pipeline"
editingUnsupported = "Displaying these tool params for editing is not supported yet."
moveDown = "Move down"
moveUp = "Move up"
name = "Name"
namePlaceholder = "e.g. Redaction sweep"
noSources = "No sources connected yet. The pipeline can still run on files supplied to it directly."
noToolSettings = "This tool has no configurable settings."
operations_one = "Operation ({{count}})"
operations_other = "Operations ({{count}})"
output = "Output"
@@ -7257,8 +7278,6 @@ save = "Save changes"
scheduleEvery = "Run every"
sources = "Sources"
sourcesLoading = "Loading sources..."
subtitle = "Pick the sources it runs over, chain the operations, then choose when it runs and where output goes."
title = "New pipeline"
trigger = "Trigger"
triggerManual = "Manual only"
@@ -7274,18 +7293,8 @@ confirm = "Delete"
title = "Delete pipeline?"
[portal.pipelines.detail]
closeAriaLabel = "Close detail"
delete = "Delete pipeline"
edit = "Edit"
noSources = "No sources. Files are supplied directly to each run."
noSteps = "No operations configured."
output = "Output"
pause = "Pause"
resume = "Resume"
run = "Run now"
sources = "Sources"
steps = "Operations"
subtitle = "{{trigger}} · {{status}}"
[portal.pipelines.empty]
action = "Create a pipeline"
@@ -7307,6 +7316,7 @@ completed_other = "All {{count}} runs completed."
empty = "Nothing to run: the sources had no documents to process."
failed = "Run failed: {{error}}"
running = "Run started; still in progress."
timeout = "Run is taking longer than expected; it may still finish in the background."
[portal.pipelines.status]
active = "Active"
@@ -71,6 +71,8 @@ export const redactOperationConfig = defineSingleFileTool({
operationType: "redact",
endpoint: (parameters: RedactParameters) =>
parameters.mode === "automatic" ? AUTO_ENDPOINT : null,
// Routing set: `mode` is frontend-only, so a stored step matches by this rather than by replay.
endpoints: [AUTO_ENDPOINT],
defaultParameters,
});
@@ -1,9 +1,18 @@
import { describe, expect, test } from "vitest";
import {
isToolEndpoint,
objectToFormData,
type ToolApiParams,
} from "@app/hooks/tools/shared/toolApiMapping";
describe("isToolEndpoint", () => {
test("accepts a generated endpoint and rejects an unknown path", () => {
expect(isToolEndpoint("/api/v1/misc/compress-pdf")).toBe(true);
expect(isToolEndpoint("/api/v1/misc/not-a-real-tool")).toBe(false);
expect(isToolEndpoint("")).toBe(false);
});
});
describe("objectToFormData", () => {
test("serializes primitive fields to string form values", () => {
const request: ToolApiParams["/api/v1/misc/compress-pdf"] = {
@@ -1,4 +1,5 @@
import {
TOOL_ENDPOINTS,
type ToolApiParams,
type ToolApiRequest,
type ToolEndpoint,
@@ -6,6 +7,16 @@ import {
export type { ToolApiParams, ToolApiRequest, ToolEndpoint };
const TOOL_ENDPOINT_SET: ReadonlySet<string> = new Set(TOOL_ENDPOINTS);
/**
* Runtime check that a string is one of the generated tool endpoints, so callers can narrow an
* arbitrary endpoint path to {@link ToolEndpoint} against the real supported set rather than casting.
*/
export function isToolEndpoint(value: string): value is ToolEndpoint {
return TOOL_ENDPOINT_SET.has(value);
}
/**
* Mapping for tools that take only a file and have no request parameters (their
* generated model is `Record<string, never>`). Both directions are empty; the
@@ -0,0 +1,215 @@
import { describe, expect, test } from "vitest";
import {
ToolCategoryId,
SubcategoryId,
type ToolRegistry,
type ToolRegistryEntry,
} from "@app/data/toolsTaxonomy";
import { type ToolId } from "@app/types/toolId";
import {
asRegistryConfig,
ToolType,
} from "@app/hooks/tools/shared/toolOperationTypes";
import {
deserializeToolStep,
getExecutableTools,
serializeToolStep,
stepRequiresUpload,
type WorkingToolStep,
} from "@app/hooks/tools/shared/toolAutomation";
import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
import { defaultParameters as compressDefaults } from "@app/hooks/tools/compress/useCompressParameters";
import { splitOperationConfig } from "@app/hooks/tools/split/useSplitOperation";
import { SPLIT_METHODS } from "@app/constants/splitConstants";
import { redactOperationConfig } from "@app/hooks/tools/redact/useRedactOperation";
function entry(over: Partial<ToolRegistryEntry>): ToolRegistryEntry {
return {
icon: null,
name: "",
component: null,
description: "",
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
automationSettings: null,
...over,
};
}
const NoopSettings = () => null;
// A migrated, param-less config (mappers present, no settings UI) -> "noSettings".
const repairConfig = asRegistryConfig({
toolType: ToolType.singleFile,
operationType: "repair",
endpoint: "/api/v1/misc/repair",
defaultParameters: {},
buildFormData: () => new FormData(),
toApiParams: () => ({}),
fromApiParams: () => ({}),
});
// A config with no mappers (not migrated) -> "unsupported".
const changeMetadataConfig = asRegistryConfig({
toolType: ToolType.singleFile,
operationType: "changeMetadata",
endpoint: "/api/v1/misc/update-metadata",
defaultParameters: {},
buildFormData: () => new FormData(),
});
const registry: Partial<ToolRegistry> = {
compress: entry({
name: "Compress",
automationSettings: NoopSettings,
operationConfig: asRegistryConfig(compressOperationConfig),
}),
repair: entry({ name: "Repair", operationConfig: repairConfig }),
changeMetadata: entry({
name: "Change metadata",
automationSettings: NoopSettings,
operationConfig: changeMetadataConfig,
}),
// Excluded: automation explicitly off.
sign: entry({
name: "Sign",
supportsAutomate: false,
operationConfig: repairConfig,
}),
// Excluded: no operationConfig at all.
extractPages: entry({ name: "Extract pages" }),
};
// Separate registry so these don't change the getExecutableTools expectations above.
const dynamicRegistry: Partial<ToolRegistry> = {
split: entry({
name: "Split",
automationSettings: NoopSettings,
operationConfig: asRegistryConfig(splitOperationConfig),
}),
redact: entry({
name: "Redact",
automationSettings: NoopSettings,
operationConfig: asRegistryConfig(redactOperationConfig),
}),
};
describe("getExecutableTools", () => {
test("lists automatable tools with a resolvable endpoint, classified by support", () => {
const tools = getExecutableTools(registry);
expect(tools.map((t) => t.toolId)).toEqual([
"changeMetadata",
"compress",
"repair",
]);
expect(Object.fromEntries(tools.map((t) => [t.toolId, t.support]))).toEqual(
{
compress: "editable",
repair: "noSettings",
changeMetadata: "unsupported",
},
);
});
});
describe("serialize/deserialize round-trip", () => {
test("compress maps UI params to the backend body and back", () => {
const step: WorkingToolStep = {
toolId: "compress" as ToolId,
operation: "/api/v1/misc/compress-pdf",
params: {
...compressDefaults,
compressionLevel: 7,
compressionMethod: "filesize",
fileSizeValue: "2",
fileSizeUnit: "MB",
},
support: "editable",
};
const api = serializeToolStep(step, registry);
expect(api.operation).toBe("/api/v1/misc/compress-pdf");
expect(api.parameters).toMatchObject({
optimizeLevel: 7,
expectedOutputSize: "2MB",
});
const back = deserializeToolStep(api, registry);
expect(back.toolId).toBe("compress");
expect(back.params).toMatchObject({
compressionLevel: 7,
compressionMethod: "filesize",
fileSizeValue: "2",
fileSizeUnit: "MB",
});
});
test("an unknown endpoint is preserved as an unmapped step", () => {
const step = deserializeToolStep(
{ operation: "/api/v1/unknown/thing", parameters: { keep: true } },
registry,
);
expect(step.toolId).toBeNull();
expect(step.support).toBe("unknown");
expect(serializeToolStep(step, registry)).toEqual({
operation: "/api/v1/unknown/thing",
parameters: { keep: true },
});
});
test("a dynamic-endpoint tool (split by chapters) round-trips as an editable step", () => {
const step: WorkingToolStep = {
toolId: "split" as ToolId,
operation: "/api/v1/general/split-pdf-by-chapters",
params: { method: SPLIT_METHODS.BY_CHAPTERS, bookmarkLevel: "2" },
support: "editable",
};
const api = serializeToolStep(step, dynamicRegistry);
expect(api.operation).toBe("/api/v1/general/split-pdf-by-chapters");
expect(api.parameters).toMatchObject({ bookmarkLevel: 2 });
// No `method` in the stored body, so this only matches via the declared endpoint set.
const back = deserializeToolStep(api, dynamicRegistry);
expect(back.toolId).toBe("split");
expect(back.support).toBe("editable");
expect(back.operation).toBe("/api/v1/general/split-pdf-by-chapters");
expect(back.params).toMatchObject({ method: SPLIT_METHODS.BY_CHAPTERS });
});
test("a dynamic-endpoint tool whose routing field is dropped (redact) stays editable", () => {
const step: WorkingToolStep = {
toolId: "redact" as ToolId,
operation: "/api/v1/security/auto-redact",
params: { mode: "automatic", wordsToRedact: ["secret"] },
support: "editable",
};
const api = serializeToolStep(step, dynamicRegistry);
expect(api.operation).toBe("/api/v1/security/auto-redact");
expect(api.parameters).not.toHaveProperty("mode");
// No `mode` in the body, so it only matches via the declared set (replay would yield null).
const back = deserializeToolStep(api, dynamicRegistry);
expect(back.toolId).toBe("redact");
expect(back.support).toBe("editable");
expect(back.operation).toBe("/api/v1/security/auto-redact");
expect(back.params).toMatchObject({ mode: "automatic" });
});
});
describe("stepRequiresUpload", () => {
const step = (params: Record<string, unknown>): WorkingToolStep => ({
toolId: "compress" as ToolId,
operation: "/api/v1/misc/compress-pdf",
params,
support: "editable",
});
test("detects a File (or list of Files) among the parameters", () => {
const image = new File(["x"], "logo.png", { type: "image/png" });
expect(stepRequiresUpload(step({ level: 5 }))).toBe(false);
expect(stepRequiresUpload(step({ watermarkImage: image }))).toBe(true);
expect(stepRequiresUpload(step({ attachments: [image] }))).toBe(true);
});
});
@@ -0,0 +1,260 @@
/**
* Registry-level automation logic: which tools can be run as a backend operation step, how much
* of their parameters can be edited in a UI, and conversion between a tool's frontend parameter
* shape and the backend step contract (endpoint + backend parameters).
*
* This is core behaviour shared by every surface that composes or replays tool operations against
* the backend engine (portal pipelines today; backend-executed automations and AI plans later),
* so the "is this tool usable, and how" decision lives with the tools rather than in any one
* feature. It builds on each tool's `operationConfig` mappers (`toApiParams` / `fromApiParams`),
* keeping the frontend<->backend parameter mapping single-sourced in the tools.
*/
import { type ReactNode } from "react";
import {
getToolSupportsAutomate,
type SubcategoryId,
type ToolRegistry,
type ToolRegistryEntry,
} from "@app/data/toolsTaxonomy";
import { type ToolId } from "@app/types/toolId";
import {
isToolEndpoint,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import {
type ErasedToolParams,
type RegistryToolOperationConfig,
} from "@app/hooks/tools/shared/toolOperationTypes";
/**
* How much of a tool's parameters a UI can edit when composing a backend step:
* - `editable`: has both mappers and a settings UI -> render the settings UI.
* - `noSettings`: migrated to the mapper seam but has no parameters to configure.
* - `unsupported`: not migrated to the mapper seam -> parameters can't be mapped for editing yet;
* the step runs with the backend's defaults.
*/
export type ToolStepSupport = "editable" | "noSettings" | "unsupported";
/** A tool that can be added to a backend operation chain, with its editing support. */
export interface ExecutableTool {
toolId: ToolId;
name: string;
icon: ReactNode;
/** Subcategory the tool belongs to, so a picker can group tools without re-reading the registry. */
subcategoryId: SubcategoryId;
/** Endpoint resolved from default parameters, for display/inclusion. The stored step's endpoint is re-resolved from the configured parameters at serialization time. */
endpoint: ToolEndpoint;
support: ToolStepSupport;
}
/**
* The backend step contract: an endpoint path plus its backend-shaped parameters. `operation` is a
* plain string, not a {@link ToolEndpoint}, because this is the raw backend boundary (it mirrors
* the stored policy step) and a pipeline may reference endpoints the frontend does not model.
*/
export interface ToolApiStep {
operation: string;
parameters: Record<string, unknown>;
}
/** A step being edited in a UI that maps to a known tool: parameters are in the tool's frontend shape. */
export interface KnownToolStep {
toolId: ToolId;
operation: ToolEndpoint;
params: ErasedToolParams;
support: ToolStepSupport;
}
/** A stored step whose endpoint maps to no known tool: preserved verbatim, not editable. */
export interface UnknownToolStep {
toolId: null;
operation: string;
params: ErasedToolParams;
support: "unknown";
}
/** A step being edited in a UI, discriminated by whether its endpoint maps to a known tool. */
export type WorkingToolStep = KnownToolStep | UnknownToolStep;
/**
* Resolve a tool's endpoint from parameters. Static endpoints ignore params; dynamic ones may
* return undefined if the params don't determine one. The result is validated against the generated
* endpoint set (via {@link isToolEndpoint}) rather than cast, so a config endpoint that is not a
* known {@link ToolEndpoint} (e.g. a custom tool's arbitrary string) resolves to undefined.
*/
function resolveEndpoint(
config: RegistryToolOperationConfig | undefined,
params: ErasedToolParams,
): ToolEndpoint | undefined {
const endpoint = config?.endpoint;
if (typeof endpoint === "string") {
return isToolEndpoint(endpoint) ? endpoint : undefined;
}
if (typeof endpoint === "function") {
const resolved = safeCall(endpoint, params);
return typeof resolved === "string" && isToolEndpoint(resolved)
? resolved
: undefined;
}
return undefined;
}
/** Invoke a dynamic-endpoint function defensively; a throw means the params don't determine one. */
function safeCall(
fn: (params: ErasedToolParams) => string | null | undefined,
params: ErasedToolParams,
): string | null | undefined {
try {
return fn(params);
} catch {
return undefined;
}
}
/** Classify how much of a tool's parameters a UI can edit when composing a step. */
export function classifyToolStepSupport(
entry: ToolRegistryEntry,
): ToolStepSupport {
const config = entry.operationConfig;
const hasMappers = Boolean(config?.toApiParams && config?.fromApiParams);
if (!hasMappers) return "unsupported";
return entry.automationSettings ? "editable" : "noSettings";
}
function isFileValue(value: unknown): boolean {
if (typeof File === "undefined") return false;
if (value instanceof File) return true;
return Array.isArray(value) && value.some((item) => item instanceof File);
}
/**
* True if any of a step's parameters is an uploaded file (or list of files). Such a step cannot be
* saved into a stored pipeline yet: the file bytes are not persisted with the policy, so a later
* (e.g. scheduled) run would have nothing to send for that named file field.
*/
export function stepRequiresUpload(step: WorkingToolStep): boolean {
return Object.values(step.params).some(isFileValue);
}
/**
* The tools that can be run as a backend operation step, sorted by name. Includes only automatable
* tools whose endpoint resolves from defaults (so they can become a backend step); this drops
* tools with no operationConfig and tools whose endpoint needs runtime input (e.g. convert).
*/
export function getExecutableTools(
registry: Partial<ToolRegistry>,
): ExecutableTool[] {
const tools: ExecutableTool[] = [];
for (const [id, entry] of Object.entries(registry)) {
if (!entry || !getToolSupportsAutomate(entry)) continue;
const config = entry.operationConfig;
if (!config) continue;
const endpoint = resolveEndpoint(config, config.defaultParameters ?? {});
if (!endpoint) continue;
tools.push({
toolId: id as ToolId,
name: entry.name,
icon: entry.icon,
subcategoryId: entry.subcategoryId,
endpoint,
support: classifyToolStepSupport(entry),
});
}
return tools.sort((a, b) => a.name.localeCompare(b.name));
}
/** A fresh working step for a tool just added to a chain, seeded with its default parameters. */
export function newWorkingToolStep(
tool: ExecutableTool,
registry: Partial<ToolRegistry>,
): KnownToolStep {
const config = registry[tool.toolId]?.operationConfig;
return {
toolId: tool.toolId,
operation: tool.endpoint,
params: { ...(config?.defaultParameters ?? {}) },
support: tool.support,
};
}
/** Serialize a working step into the backend step contract (endpoint + backend parameters). */
export function serializeToolStep(
step: WorkingToolStep,
registry: Partial<ToolRegistry>,
): ToolApiStep {
const config =
step.toolId !== null ? registry[step.toolId]?.operationConfig : undefined;
if (!config) {
// Unmapped step (unknown endpoint on edit): round-trip it unchanged.
return { operation: step.operation, parameters: step.params };
}
const merged = { ...(config.defaultParameters ?? {}), ...step.params };
const operation = resolveEndpoint(config, merged) ?? step.operation;
const parameters = config.toApiParams
? (config.toApiParams(merged) as Record<string, unknown>)
: {};
return { operation, parameters };
}
/**
* Find the registry tool for a stored step's endpoint: exact match for static endpoints, else
* membership in a dynamic tool's declared `endpoints` set (replaying its function can't recover a
* frontend-only routing field). Replay is only the fallback when no set is declared.
*/
function findToolByEndpoint(
step: ToolApiStep,
registry: Partial<ToolRegistry>,
): [ToolId, ToolRegistryEntry] | undefined {
let dynamic: [ToolId, ToolRegistryEntry] | undefined;
for (const [id, entry] of Object.entries(registry)) {
const endpoint = entry?.operationConfig?.endpoint;
if (typeof endpoint === "string") {
if (endpoint === step.operation) return [id as ToolId, entry];
} else if (typeof endpoint === "function" && !dynamic) {
const declared = entry?.operationConfig?.endpoints;
const matched = declared
? declared.some((e) => e === step.operation)
: safeCall(endpoint, step.parameters) === step.operation;
if (matched) dynamic = [id as ToolId, entry];
}
}
return dynamic;
}
/** A stored step kept verbatim because its endpoint maps to no known tool. */
function unmappedStep(step: ToolApiStep): UnknownToolStep {
return {
toolId: null,
operation: step.operation,
params: { ...step.parameters },
support: "unknown",
};
}
/**
* Rehydrate a stored backend step into a working step for editing: map the endpoint back to a tool
* and its backend parameters back to the frontend shape via `fromApiParams`. Steps whose endpoint
* maps to no known tool are kept as an unmapped, non-editable working step (parameters preserved).
*/
export function deserializeToolStep(
step: ToolApiStep,
registry: Partial<ToolRegistry>,
): WorkingToolStep {
const match = findToolByEndpoint(step, registry);
if (!match) return unmappedStep(step);
const [toolId, entry] = match;
const config = entry.operationConfig;
const params: ErasedToolParams = config?.fromApiParams
? {
...(config.defaultParameters ?? {}),
...config.fromApiParams(step.parameters as never),
}
: { ...(config?.defaultParameters ?? {}) };
// Validate against the generated endpoint set instead of casting the matched string.
const operation =
resolveEndpoint(config, params) ??
(isToolEndpoint(step.operation) ? step.operation : undefined);
if (operation === undefined) return unmappedStep(step);
return { toolId, operation, params, support: classifyToolStepSupport(entry) };
}
@@ -101,9 +101,9 @@ interface BaseToolOperationConfig<TParams, TEndpoint extends ToolEndpoint> {
consumesAllInputs?: boolean;
}
export interface SingleFileToolOperationConfig<
interface SingleFileToolBody<
TParams,
TEndpoint extends ToolEndpoint = ToolEndpoint,
TEndpoint extends ToolEndpoint,
> extends BaseToolOperationConfig<TParams, TEndpoint> {
/** This tool processes one file at a time. */
toolType: ToolType.singleFile;
@@ -111,18 +111,19 @@ export interface SingleFileToolOperationConfig<
/** Builds FormData for API request. */
buildFormData: (params: TParams, file: File) => FormData;
/**
* API endpoint for the operation, or a function for dynamic routing. `null`
* when the operation has no backend endpoint (see {@link ToolOperationEndpoint}).
*/
endpoint: TEndpoint | null | ((params: TParams) => TEndpoint | null);
customProcessor?: undefined;
}
export interface MultiFileToolOperationConfig<
/** Single-file tool config; see {@link EndpointBinding} for the endpoint/endpoints rule. */
export type SingleFileToolOperationConfig<
TParams,
TEndpoint extends ToolEndpoint = ToolEndpoint,
> = SingleFileToolBody<TParams, TEndpoint> &
EndpointBinding<TParams, TEndpoint>;
interface MultiFileToolBody<
TParams,
TEndpoint extends ToolEndpoint,
> extends BaseToolOperationConfig<TParams, TEndpoint> {
/** This tool processes multiple files at once. */
toolType: ToolType.multiFile;
@@ -133,15 +134,15 @@ export interface MultiFileToolOperationConfig<
/** Builds FormData for API request. */
buildFormData: (params: TParams, files: File[]) => FormData;
/**
* API endpoint for the operation, or a function for dynamic routing. `null`
* when the operation has no backend endpoint (see {@link ToolOperationEndpoint}).
*/
endpoint: TEndpoint | null | ((params: TParams) => TEndpoint | null);
customProcessor?: undefined;
}
/** Multi-file counterpart of {@link SingleFileToolOperationConfig}. */
export type MultiFileToolOperationConfig<
TParams,
TEndpoint extends ToolEndpoint = ToolEndpoint,
> = MultiFileToolBody<TParams, TEndpoint> & EndpointBinding<TParams, TEndpoint>;
export interface CustomToolOperationConfig<
TParams,
> extends BaseToolOperationConfig<TParams, ToolEndpoint> {
@@ -157,6 +158,9 @@ export interface CustomToolOperationConfig<
*/
endpoint?: string | ((params: TParams) => string | undefined);
/** `never` so `endpoints` stays readable across the union; custom tools declare no set. */
endpoints?: never;
/**
* Custom processing logic that completely bypasses standard file processing.
* This tool handles all API calls, response processing, and file creation.
@@ -180,6 +184,23 @@ export type ToolOperationConfig<
| MultiFileToolOperationConfig<TParams, TEndpoint>
| CustomToolOperationConfig<TParams>;
/**
* A static `endpoint` declares no set; a dynamic (function) `endpoint` must declare its full
* `endpoints` set, which is how findToolByEndpoint maps a stored step back to its tool when the
* endpoint-selecting parameter is frontend-only.
*/
type EndpointBinding<TParams, TEndpoint extends ToolEndpoint> =
| { endpoint: TEndpoint | null; endpoints?: never }
| {
endpoint: (params: TParams) => TEndpoint | null;
endpoints: readonly TEndpoint[];
};
/** Union-distributing Omit, so stripping a key from a discriminated config keeps its branches. */
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown
? Omit<T, K>
: never;
/**
* Define a single-file tool's operation config. Infers the endpoint literal from
* `endpoint` and binds toApiParams/fromApiParams to that endpoint's request
@@ -189,9 +210,15 @@ export function defineSingleFileTool<
TParams,
const TEndpoint extends ToolEndpoint,
>(
config: Omit<SingleFileToolOperationConfig<TParams, TEndpoint>, "toolType">,
config: DistributiveOmit<
SingleFileToolOperationConfig<TParams, TEndpoint>,
"toolType"
>,
): SingleFileToolOperationConfig<TParams, TEndpoint> {
return { ...config, toolType: ToolType.singleFile };
return {
...config,
toolType: ToolType.singleFile,
} as SingleFileToolOperationConfig<TParams, TEndpoint>;
}
/** Multi-file counterpart of {@link defineSingleFileTool}. */
@@ -199,9 +226,15 @@ export function defineMultiFileTool<
TParams,
const TEndpoint extends ToolEndpoint,
>(
config: Omit<MultiFileToolOperationConfig<TParams, TEndpoint>, "toolType">,
config: DistributiveOmit<
MultiFileToolOperationConfig<TParams, TEndpoint>,
"toolType"
>,
): MultiFileToolOperationConfig<TParams, TEndpoint> {
return { ...config, toolType: ToolType.multiFile };
return {
...config,
toolType: ToolType.multiFile,
} as MultiFileToolOperationConfig<TParams, TEndpoint>;
}
/**
@@ -177,6 +177,8 @@ export const splitOperationConfig = defineSingleFileTool({
fromApiParams: splitFromApiParams,
operationType: "split",
endpoint: getSplitEndpoint,
// Full routing set: a stored step maps back to Split though its `method` selector is frontend-only.
endpoints: Array.from(new Set(Object.values(SPLIT_ENDPOINTS))),
defaultParameters,
});
@@ -3,6 +3,7 @@ import { Home } from "@portal/views/Home";
import { Users } from "@portal/views/Users";
import { Documents } from "@portal/views/Documents";
import { Pipelines } from "@portal/views/Pipelines";
import { PipelineBuilder } from "@portal/views/PipelineBuilder";
import { Sources } from "@portal/views/Sources";
import { AgentBuilder } from "@portal/views/AgentBuilder";
import { Policies } from "@portal/views/Policies";
@@ -26,6 +27,14 @@ export function ViewRouter() {
<Route index element={<Home />} />
<Route path={rel(VIEW_PATHS.users)} element={<Users />} />
<Route path={rel(VIEW_PATHS.pipelines)} element={<Pipelines />} />
<Route
path={`${rel(VIEW_PATHS.pipelines)}/new`}
element={<PipelineBuilder />}
/>
<Route
path={`${rel(VIEW_PATHS.pipelines)}/:id`}
element={<PipelineBuilder />}
/>
<Route path={rel(VIEW_PATHS.sources)} element={<Sources />} />
<Route
path={rel(VIEW_PATHS["agent-builder"])}
@@ -1,5 +1,6 @@
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
import { ErrorBoundary } from "@portal/components/ErrorBoundary";
import { useUI } from "@portal/contexts/UIContext";
import { AppShell } from "@portal/components/AppShell";
@@ -72,9 +73,12 @@ export function PortalChrome() {
return (
<>
<GlobalShortcuts />
<AppShell>
<RoutedContent />
</AppShell>
{/* The pipeline builder reads the tool registry to list and configure operations. */}
<ToolRegistryProvider>
<AppShell>
<RoutedContent />
</AppShell>
</ToolRegistryProvider>
<AssistantButton />
<AssistantPanel />
<SearchModal />
@@ -1,477 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActionIcon,
Banner,
Button,
Checkbox,
Chip,
FormField,
Input,
Modal,
RadioGroup,
Select,
} from "@app/ui";
import { errorMessage } from "@portal/api/http";
import {
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";
type OutputMode = "inline" | "folder";
type ScheduleUnit = "MINUTES" | "HOURS" | "DAYS";
const SCHEDULE_UNITS: ScheduleUnit[] = ["MINUTES", "HOURS", "DAYS"];
/** Empty trigger type = manual-only (no automatic trigger). */
const MANUAL = "";
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;
}
/**
* 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 isEdit = pipeline !== undefined;
const sourcesState = useAsync<SourceView[]>(
async () => (open ? (await fetchSources()).sources : []),
[open],
);
const availableSources = sourcesState.data ?? [];
// 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<TriggerInfo[]>(
async () => (open ? await fetchTriggers() : []),
[open],
);
const triggers = useMemo(
() => triggersState.data ?? [],
[triggersState.data],
);
const [name, setName] = useState("");
const [sourceIds, setSourceIds] = useState<string[]>([]);
const [steps, setSteps] = useState<PipelineStep[]>([]);
const [triggerType, setTriggerType] = useState<string>(MANUAL);
const [scheduleCount, setScheduleCount] = useState("1");
const [scheduleUnit, setScheduleUnit] = useState<ScheduleUnit>("HOURS");
const [outputMode, setOutputMode] = useState<OutputMode>("inline");
const [outputDirectory, setOutputDirectory] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// 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 addStep(operation: string, parameters: Record<string, unknown>) {
setSteps((current) => [
...current,
{ operation, parameters: { ...parameters } },
]);
}
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("portal.pipelines.composer.triggerManual") },
...triggers.map((trigger) => ({
value: trigger.type,
label: t(`portal.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 (
<Modal
open={open}
onClose={onClose}
width="lg"
title={
isEdit
? t("portal.pipelines.composer.editTitle")
: t("portal.pipelines.composer.title")
}
subtitle={t("portal.pipelines.composer.subtitle")}
footer={
<div className="portal-pipelines__composer-footer">
<Button
variant="tertiary"
size="sm"
disabled={submitting}
onClick={onClose}
>
{t("portal.pipelines.composer.cancel")}
</Button>
<Button
size="sm"
onClick={submit}
loading={submitting}
disabled={!canSave}
>
{isEdit
? t("portal.pipelines.composer.save")
: t("portal.pipelines.composer.create")}
</Button>
</div>
}
>
<div className="portal-pipelines__composer">
<FormField label={t("portal.pipelines.composer.name")} required>
<Input
value={name}
placeholder={t("portal.pipelines.composer.namePlaceholder")}
onChange={(e) => setName(e.target.value)}
/>
</FormField>
{/* Sources */}
<div className="portal-pipelines__composer-section">
<span className="portal-pipelines__detail-heading">
{t("portal.pipelines.composer.sources")}
</span>
{sourcesState.loading ? (
<p className="portal-pipelines__muted">
{t("portal.pipelines.composer.sourcesLoading")}
</p>
) : availableSources.length === 0 ? (
<p className="portal-pipelines__muted">
{t("portal.pipelines.composer.noSources")}
</p>
) : (
<div className="portal-pipelines__source-list">
{availableSources.map((source) => (
<Checkbox
key={source.id}
checked={sourceIds.includes(source.id)}
onChange={(e) => toggleSource(source.id, e.target.checked)}
label={source.name}
/>
))}
</div>
)}
<span className="portal-pipelines__detail-heading">
{t("portal.pipelines.composer.trigger")}
</span>
<RadioGroup<string>
name="pipeline-trigger"
value={triggerType}
onChange={setTriggerType}
direction="horizontal"
options={triggerOptions}
/>
{triggerType === "schedule" && (
<div className="portal-pipelines__schedule">
<span className="portal-pipelines__muted">
{t("portal.pipelines.composer.scheduleEvery")}
</span>
<Input
inputSize="sm"
type="number"
min={1}
value={scheduleCount}
invalid={!scheduleCountValid}
onChange={(e) => setScheduleCount(e.target.value)}
className="portal-pipelines__schedule-count"
/>
<Select
inputSize="sm"
value={scheduleUnit}
onChange={(value) =>
setScheduleUnit((value ?? "") as ScheduleUnit)
}
options={SCHEDULE_UNITS.map((unit) => ({
value: unit,
label: t(
`portal.pipelines.composer.unit.${unit.toLowerCase()}`,
),
}))}
/>
</div>
)}
</div>
{/* Operations */}
<div className="portal-pipelines__composer-section">
<span className="portal-pipelines__detail-heading">
{t("portal.pipelines.composer.operations", { count: steps.length })}
</span>
{steps.length === 0 ? (
<p className="portal-pipelines__muted">
{t("portal.pipelines.composer.chainEmpty")}
</p>
) : (
<ol className="portal-pipelines__chain">
{steps.map((step, i) => (
<li
key={`${step.operation}-${i}`}
className="portal-pipelines__chain-row"
>
<span className="portal-pipelines__chain-index">{i + 1}</span>
<span className="portal-pipelines__chain-op">
{humanizeOperation(step.operation)}
</span>
<div className="portal-pipelines__chain-actions">
<ActionIcon
type="button"
aria-label={t("portal.pipelines.composer.moveUp")}
disabled={i === 0}
onClick={() => moveStep(i, -1)}
>
↑
</ActionIcon>
<ActionIcon
type="button"
aria-label={t("portal.pipelines.composer.moveDown")}
disabled={i === steps.length - 1}
onClick={() => moveStep(i, 1)}
>
↓
</ActionIcon>
<ActionIcon
type="button"
aria-label={t("portal.pipelines.composer.removeStep")}
onClick={() => removeStep(i)}
>
×
</ActionIcon>
</div>
</li>
))}
</ol>
)}
<div className="portal-pipelines__op-palette">
{PIPELINE_OPERATIONS.map((op) => (
<Chip
key={op.operation}
accent="default"
size="sm"
onClick={() => addStep(op.operation, op.parameters)}
>
{`+ ${humanizeOperation(op.operation)}`}
</Chip>
))}
</div>
</div>
{/* Output */}
<div className="portal-pipelines__composer-section">
<span className="portal-pipelines__detail-heading">
{t("portal.pipelines.composer.output")}
</span>
<RadioGroup<OutputMode>
name="pipeline-output"
value={outputMode}
onChange={setOutputMode}
direction="horizontal"
options={[
{ value: "inline", label: t("portal.pipelines.output.inline") },
{ value: "folder", label: t("portal.pipelines.output.folder") },
]}
/>
{outputMode === "folder" && (
<FormField
label={t("portal.pipelines.composer.directory")}
helperText={t("portal.pipelines.composer.directoryHelp")}
required
>
<Input
value={outputDirectory}
placeholder="/data/processed"
onChange={(e) => setOutputDirectory(e.target.value)}
/>
</FormField>
)}
</div>
{error && <Banner tone="danger" description={error} />}
</div>
</Modal>
);
}
@@ -1,215 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActionIcon, Banner, Button, Chip } from "@app/ui";
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<RunResult | null>(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<PolicyRunView | null> {
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("portal.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("portal.pipelines.run.failed", { error: failed.error ?? "" }),
});
} else if (finals.every((r) => r?.status === "COMPLETED")) {
setRunResult({
tone: "success",
text: t("portal.pipelines.run.completed", { count: finals.length }),
});
} else {
// Still running when we stopped polling, or cancelled.
setRunResult({ tone: "info", text: t("portal.pipelines.run.running") });
}
} catch (e) {
if (mounted.current)
setRunResult({ tone: "danger", text: errorMessage(e) });
} finally {
if (mounted.current) setRunning(false);
}
}
return (
<section className="portal-pipelines__expanded">
<header className="portal-pipelines__expanded-head">
<span className="portal-pipelines__pipe-dot" aria-hidden>
⛓
</span>
<div>
<h2 className="portal-pipelines__expanded-title">{pipeline.name}</h2>
<span className="portal-pipelines__expanded-sub">
{t("portal.pipelines.detail.subtitle", {
trigger: t(`portal.pipelines.trigger.${pipeline.trigger}`, {
defaultValue: pipeline.trigger,
}),
status: t(`portal.pipelines.status.${pipeline.status}`),
})}
</span>
</div>
<ActionIcon
type="button"
className="portal-pipelines__expanded-close"
onClick={onClose}
aria-label={t("portal.pipelines.detail.closeAriaLabel")}
>
×
</ActionIcon>
</header>
<div className="portal-pipelines__detail">
<div className="portal-pipelines__detail-section">
<span className="portal-pipelines__detail-heading">
{t("portal.pipelines.detail.steps")}
</span>
{pipeline.steps.length === 0 ? (
<p className="portal-pipelines__muted">
{t("portal.pipelines.detail.noSteps")}
</p>
) : (
<div className="portal-pipelines__chips">
{pipeline.steps.map((step, i) => (
<Chip key={`${step}-${i}`} size="sm">
{`${i + 1}. ${humanizeOperation(step)}`}
</Chip>
))}
</div>
)}
</div>
<div className="portal-pipelines__detail-section">
<span className="portal-pipelines__detail-heading">
{t("portal.pipelines.detail.sources")}
</span>
{pipeline.sources.length === 0 ? (
<p className="portal-pipelines__muted">
{t("portal.pipelines.detail.noSources")}
</p>
) : (
<div className="portal-pipelines__chips">
{pipeline.sources.map((source) => (
<Chip key={source.id} accent="neutral" size="sm">
{source.name}
</Chip>
))}
</div>
)}
</div>
<div className="portal-pipelines__detail-section">
<span className="portal-pipelines__detail-heading">
{t("portal.pipelines.detail.output")}
</span>
<Chip accent="premium" size="sm">
{t(`portal.pipelines.output.${pipeline.output}`, {
defaultValue: pipeline.output,
})}
</Chip>
</div>
</div>
{runResult && (
<Banner tone={runResult.tone} description={runResult.text} />
)}
<div className="portal-pipelines__detail-actions">
<Button loading={running} disabled={busy} onClick={handleRun}>
{t("portal.pipelines.detail.run")}
</Button>
<Button
variant="secondary"
disabled={busy || running}
onClick={() => onEdit(pipeline)}
>
{t("portal.pipelines.detail.edit")}
</Button>
<Button
variant="secondary"
disabled={busy || running}
onClick={() => onTogglePause(pipeline)}
>
{paused
? t("portal.pipelines.detail.resume")
: t("portal.pipelines.detail.pause")}
</Button>
<Button
accent="danger"
variant="secondary"
disabled={busy || running}
onClick={() => onDelete(pipeline)}
>
{t("portal.pipelines.detail.delete")}
</Button>
</div>
</section>
);
}
@@ -0,0 +1,59 @@
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import { Banner } from "@app/ui";
import { type ToolRegistry } from "@app/data/toolsTaxonomy";
import { type ErasedToolParams } from "@app/hooks/tools/shared/toolOperationTypes";
import { type WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
interface PipelineStepSettingsProps {
step: WorkingToolStep;
registry: Partial<ToolRegistry>;
onChange: (params: ErasedToolParams) => void;
}
/**
* Renders the parameter editor for one pipeline step, chosen by the tool's capability:
* the tool's own settings UI when editable, an explanatory note when it has no parameters,
* or a "not supported yet" fallback for tools not yet migrated to the mapper seam.
*/
export function PipelineStepSettings({
step,
registry,
onChange,
}: PipelineStepSettingsProps) {
const { t } = useTranslation();
if (step.support === "noSettings") {
return (
<Banner
tone="info"
description={t("portal.pipelines.composer.noToolSettings")}
/>
);
}
const entry = step.toolId ? registry[step.toolId] : undefined;
const Settings =
step.support === "editable" ? entry?.automationSettings : null;
if (!Settings) {
return (
<Banner
tone="warning"
description={t("portal.pipelines.composer.editingUnsupported")}
/>
);
}
return (
<Suspense fallback={null}>
<Settings
parameters={step.params}
onParameterChange={(key, value) =>
onChange({ ...step.params, [key]: value })
}
disabled={false}
/>
</Suspense>
);
}
@@ -1,5 +1,6 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import ChevronRightRoundedIcon from "@mui/icons-material/ChevronRightRounded";
import {
Chip,
StatusBadge,
@@ -16,16 +17,11 @@ const STATUS_TONE: Record<PipelineStatus, StatusTone> = {
interface PipelinesTableProps {
pipelines: PipelineView[];
/** Id of the row whose detail panel is open, drives the caret state. */
expandedId: string | null;
/** A row opens that pipeline's own page. */
onRowClick: (pipeline: PipelineView) => void;
}
export function PipelinesTable({
pipelines,
expandedId,
onRowClick,
}: PipelinesTableProps) {
export function PipelinesTable({ pipelines, onRowClick }: PipelinesTableProps) {
const { t } = useTranslation();
const columns = useMemo<TableColumn<PipelineView>[]>(
() => [
@@ -90,24 +86,18 @@ export function PipelinesTable({
),
},
{
key: "expand",
key: "open",
header: "",
align: "right",
width: "2.5rem",
render: (p) => (
<span
className={
"portal-pipelines__caret" +
(expandedId === p.id ? " is-open" : "")
}
aria-hidden
>
▸
render: () => (
<span className="portal-pipelines__caret" aria-hidden>
<ChevronRightRoundedIcon style={{ fontSize: "1.25rem" }} />
</span>
),
},
],
[expandedId, t],
[t],
);
return (
@@ -0,0 +1,99 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui";
import {
getSubcategoryLabel,
SUBCATEGORY_ORDER,
type SubcategoryId,
} from "@app/data/toolsTaxonomy";
import { type ExecutableTool } from "@app/hooks/tools/shared/toolAutomation";
interface ToolPickerProps {
tools: ExecutableTool[];
onPick: (tool: ExecutableTool) => void;
onClose: () => void;
}
/**
* Type-to-filter, category-grouped tool picker for adding a step to a pipeline. Replaces the flat
* wall of tool pills so the list stays usable as the tool count grows.
*/
export function ToolPicker({ tools, onPick, onClose }: ToolPickerProps) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const groups = useMemo(() => {
const q = query.trim().toLowerCase();
const matched = q
? tools.filter((tool) => tool.name.toLowerCase().includes(q))
: tools;
const byCategory = new Map<SubcategoryId, ExecutableTool[]>();
for (const tool of matched) {
const list = byCategory.get(tool.subcategoryId) ?? [];
list.push(tool);
byCategory.set(tool.subcategoryId, list);
}
return SUBCATEGORY_ORDER.filter((id) => byCategory.has(id)).map((id) => ({
id,
label: getSubcategoryLabel(t, id),
tools: byCategory.get(id) ?? [],
}));
}, [tools, query, t]);
return (
<div
className="portal-pipelines__picker"
role="dialog"
aria-label={t("portal.pipelines.builder.addStep")}
>
<div className="portal-pipelines__picker-search">
<input
autoFocus
value={query}
placeholder={t("portal.pipelines.builder.searchTools")}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") onClose();
}}
/>
</div>
<div className="portal-pipelines__picker-list">
{groups.length === 0 ? (
<p className="portal-pipelines__picker-empty">
{t("portal.pipelines.builder.noToolMatches")}
</p>
) : (
groups.map((group) => (
<div key={group.id} className="portal-pipelines__picker-group">
<div className="portal-pipelines__picker-group-label">
{group.label}
</div>
{group.tools.map((tool) => (
<Button
key={tool.toolId}
variant="quiet"
justify="start"
fullWidth
className="portal-pipelines__picker-item"
onClick={() => onPick(tool)}
leftSection={
<span
className="portal-pipelines__picker-icon"
aria-hidden="true"
>
{tool.icon}
</span>
}
>
<span className="portal-pipelines__picker-name">
{tool.name}
</span>
</Button>
))}
</div>
))
)}
</div>
</div>
);
}
@@ -0,0 +1,394 @@
.portal-builder {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1.5rem;
max-width: 84rem;
margin: 0 auto;
}
.portal-builder__loading {
display: flex;
justify-content: center;
padding: 4rem 0;
}
/* Header */
.portal-builder__head {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding-bottom: 1rem;
border-bottom: 1px solid var(--color-border-light);
}
.portal-builder__back {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border: none;
background: none;
padding: 0;
font-size: 0.8125rem;
color: var(--color-text-4);
cursor: pointer;
white-space: nowrap;
}
.portal-builder__back:hover {
color: var(--color-text-1);
}
.portal-builder__head-main {
flex: 1;
min-width: 12rem;
}
.portal-builder__head-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* Two-pane layout */
.portal-builder__grid {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 1.25rem;
align-items: start;
}
@media (max-width: 60rem) {
.portal-builder__grid {
grid-template-columns: 1fr;
}
}
.portal-builder__flow {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.portal-builder__section-label {
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-5);
font-weight: 600;
}
.portal-builder__empty {
font-size: 0.8125rem;
color: var(--color-text-4);
margin: 0;
padding: 0.5rem 0;
}
/* Step cards */
.portal-builder__steps {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.portal-builder__step {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--color-surface);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-lg);
padding: 0.5rem 0.625rem;
transition:
border-color var(--motion-fast),
background var(--motion-fast);
}
.portal-builder__step--active {
border-color: var(--color-blue);
background: var(--color-blue-light);
}
.portal-builder__step-main {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.625rem;
border: none;
background: none;
padding: 0.25rem;
text-align: left;
cursor: pointer;
color: inherit;
}
.portal-builder__step-index {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.375rem;
height: 1.375rem;
flex-shrink: 0;
border-radius: 50%;
font-size: 0.6875rem;
font-weight: 600;
background: var(--color-blue-light);
color: var(--color-blue);
}
.portal-builder__step--active .portal-builder__step-index {
background: var(--color-blue);
color: #fff;
}
.portal-builder__step-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.portal-builder__step-name {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-1);
}
.portal-builder__step-note {
font-size: 0.6875rem;
color: var(--color-text-4);
}
.portal-builder__step-actions {
display: flex;
gap: 0.25rem;
}
.portal-builder__step-actions button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
padding: 0;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-3);
cursor: pointer;
transition:
background var(--motion-fast),
color var(--motion-fast);
}
.portal-builder__step-actions button:hover:not(:disabled) {
background: var(--color-bg-hover);
color: var(--color-text-1);
}
.portal-builder__step-actions button:disabled {
opacity: 0.4;
cursor: default;
}
.portal-builder__add-step {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
width: 100%;
padding: 0.625rem;
border: 1px dashed var(--color-border);
border-radius: var(--radius-lg);
background: none;
color: var(--color-text-3);
font-size: 0.8125rem;
cursor: pointer;
transition:
border-color var(--motion-fast),
color var(--motion-fast);
}
.portal-builder__add-step:hover {
border-color: var(--color-blue);
color: var(--color-blue);
}
/* Pipeline settings (above the operation list) */
.portal-builder__settings {
display: flex;
flex-direction: column;
gap: 0.75rem;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-lg);
padding: 1.125rem;
}
.portal-builder__settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: 1.25rem;
}
.portal-builder__settings-col {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Inspector: heading sits outside the card so it aligns with the operations heading. */
.portal-builder__inspector-col {
position: sticky;
top: 1rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.portal-builder__inspector {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: 1.125rem;
}
/* Tool picker */
.portal-pipelines__picker {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
overflow: hidden;
}
.portal-pipelines__picker-search {
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border-light);
}
.portal-pipelines__picker-search input {
flex: 1;
border: none;
background: none;
padding: 0;
font-size: 0.875rem;
color: var(--color-text-1);
outline: none;
}
.portal-pipelines__picker-list {
max-height: 20rem;
overflow-y: auto;
padding: 0.25rem 0;
}
.portal-pipelines__picker-group-label {
padding: 0.5rem 0.75rem 0.25rem;
font-size: 0.6875rem;
color: var(--color-text-5);
}
.portal-pipelines__picker-item {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
border: none;
background: none;
padding: 0.4375rem 0.75rem;
text-align: left;
cursor: pointer;
color: var(--color-text-1);
transition: background var(--motion-fast);
}
.portal-pipelines__picker-item:hover {
background: var(--color-bg-hover);
}
.portal-pipelines__picker-icon {
display: inline-flex;
align-items: center;
color: var(--color-text-3);
font-size: 1.125rem;
}
.portal-pipelines__picker-name {
font-size: 0.8125rem;
}
.portal-pipelines__picker-empty {
padding: 1rem 0.75rem;
font-size: 0.8125rem;
color: var(--color-text-4);
margin: 0;
}
/* The back link, step row, add-step affordance, tool-picker item and step
actions are the shared Button/ActionIcon carrying bespoke styling. Re-assert
their original look over the design-system button base (which otherwise
imposes a fixed height, its own padding/border and accent text colour). */
.portal-builder__back.sui-btn {
height: auto;
min-height: 0;
padding: 0;
font-weight: 400;
font-size: 0.8125rem;
color: var(--color-text-4);
}
.portal-builder__back.sui-btn:hover {
color: var(--color-text-1);
}
.portal-builder__step-main.sui-btn {
flex: 1;
height: auto;
min-height: 0;
padding: 0.25rem;
font-weight: 400;
color: inherit;
}
.portal-builder__add-step.sui-btn {
height: auto;
min-height: 0;
padding: 0.625rem;
border: 1px dashed var(--color-border);
background: none;
font-weight: 400;
font-size: 0.8125rem;
color: var(--color-text-3);
}
.portal-builder__add-step.sui-btn:hover {
border-color: var(--color-blue);
color: var(--color-blue);
}
.portal-pipelines__picker-item.sui-btn {
height: auto;
min-height: 0;
padding: 0.4375rem 0.75rem;
font-weight: 400;
color: var(--color-text-1);
}
.portal-pipelines__picker-item.sui-btn:hover {
background: var(--color-bg-hover);
}
.portal-builder__step-actions .sui-ai {
width: 1.5rem;
height: 1.5rem;
min-width: 1.5rem;
min-height: 1.5rem;
}
@@ -0,0 +1,222 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
fireEvent,
render as baseRender,
screen,
waitFor,
} from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import type { Policy } from "@portal/api/pipelines";
import type { ToolRegistryCatalog } from "@app/contexts/ToolRegistryContext";
import type { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
import { PipelineBuilder } from "@portal/views/PipelineBuilder";
const render = (
ui: Parameters<typeof baseRender>[0],
options?: Parameters<typeof baseRender>[1],
) => baseRender(ui, { wrapper: MantineProvider, ...options });
// Deterministic i18n: keys returned verbatim.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { changeLanguage: vi.fn() },
}),
}));
const fetchPipeline = vi.fn();
const fetchTriggers = vi.fn();
const savePipeline = vi.fn();
const deletePipeline = vi.fn();
const triggerPipeline = vi.fn();
const fetchRun = vi.fn();
vi.mock("@portal/api/pipelines", () => ({
fetchPipeline: (id: string) => fetchPipeline(id),
fetchTriggers: () => fetchTriggers(),
savePipeline: (policy: unknown) => savePipeline(policy),
deletePipeline: (id: string) => deletePipeline(id),
triggerPipeline: (id: string) => triggerPipeline(id),
fetchRun: (runId: string) => fetchRun(runId),
}));
const fetchSources = vi.fn();
vi.mock("@portal/api/sources", () => ({
fetchSources: () => fetchSources(),
}));
// One editable tool, Compress, so the picker and step settings have something to render.
vi.mock("@app/contexts/ToolRegistryContext", () => {
const compress = {
name: "Compress",
icon: null,
component: null,
description: "",
categoryId: "recommendedTools",
subcategoryId: "general",
automationSettings: (props: {
onParameterChange: (key: string, value: unknown) => void;
}) => (
<button
type="button"
onClick={() =>
props.onParameterChange(
"watermarkImage",
new File(["x"], "logo.png", { type: "image/png" }),
)
}
>
upload logo
</button>
),
operationConfig: {
operationType: "compress",
toolType: 0,
endpoint: "/api/v1/misc/compress-pdf",
defaultParameters: {},
buildFormData: () => new FormData(),
toApiParams: (params: Record<string, unknown>) => ({ ...params }),
fromApiParams: (params: Record<string, unknown>) => ({ ...params }),
},
} as unknown as ToolRegistryEntry;
const allTools = { compress } as unknown as ToolRegistryCatalog["allTools"];
const catalog: ToolRegistryCatalog = {
regularTools: allTools,
superTools: allTools,
linkTools: allTools,
allTools,
getToolById: () => null,
};
return { useToolRegistry: () => catalog };
});
const POLICY: Policy = {
id: "plc-1",
name: "Existing pipeline",
enabled: true,
trigger: null,
sourceIds: [],
steps: [],
output: { type: "inline", options: {} },
};
function renderBuilder(initial: string) {
return render(
<MemoryRouter initialEntries={[initial]}>
<Routes>
<Route path="/portal/pipelines/new" element={<PipelineBuilder />} />
<Route path="/portal/pipelines/:id" element={<PipelineBuilder />} />
<Route path="/portal/pipelines" element={<div>pipelines list</div>} />
</Routes>
</MemoryRouter>,
);
}
describe("PipelineBuilder", () => {
beforeEach(() => {
fetchPipeline.mockReset();
fetchTriggers.mockReset();
savePipeline.mockReset();
deletePipeline.mockReset();
triggerPipeline.mockReset();
fetchRun.mockReset();
fetchSources.mockReset();
fetchPipeline.mockResolvedValue(POLICY);
fetchTriggers.mockResolvedValue([]);
fetchSources.mockResolvedValue({ kpis: [], sources: [] });
savePipeline.mockResolvedValue({});
deletePipeline.mockResolvedValue(undefined);
triggerPipeline.mockResolvedValue(["run-1"]);
fetchRun.mockResolvedValue({ status: "COMPLETED" });
});
it("builds a new pipeline: name it, add a tool, and save", async () => {
renderBuilder("/portal/pipelines/new");
// The name field is the only textbox before the picker opens.
fireEvent.change(await screen.findByRole("textbox"), {
target: { value: "Nightly compress" },
});
fireEvent.click(screen.getByRole("button", { name: /addTool/ }));
fireEvent.click(await screen.findByText("Compress"));
fireEvent.click(screen.getByText("portal.pipelines.composer.create"));
await waitFor(() => expect(savePipeline).toHaveBeenCalledTimes(1));
expect(savePipeline).toHaveBeenCalledWith(
expect.objectContaining({
name: "Nightly compress",
trigger: null,
steps: [
expect.objectContaining({ operation: "/api/v1/misc/compress-pdf" }),
],
}),
);
expect(await screen.findByText("pipelines list")).toBeInTheDocument();
});
it("runs an existing pipeline and reports success", async () => {
renderBuilder("/portal/pipelines/plc-1");
fireEvent.click(await screen.findByText("portal.pipelines.detail.run"));
await waitFor(() => expect(triggerPipeline).toHaveBeenCalledWith("plc-1"));
expect(
await screen.findByText("portal.pipelines.run.completed"),
).toBeInTheDocument();
});
it("blocks saving a step that needs an uploaded file", async () => {
renderBuilder("/portal/pipelines/new");
fireEvent.change(await screen.findByRole("textbox"), {
target: { value: "Watermarked" },
});
fireEvent.click(screen.getByRole("button", { name: /addTool/ }));
fireEvent.click(await screen.findByText("Compress"));
// The tool's settings upload a file, which a stored pipeline can't persist yet.
fireEvent.click(await screen.findByText("upload logo"));
expect(
await screen.findByText("portal.pipelines.builder.uploadUnsupported"),
).toBeInTheDocument();
expect(
screen.getByText("portal.pipelines.composer.create").closest("button"),
).toBeDisabled();
});
it("deletes an existing pipeline after confirmation", async () => {
renderBuilder("/portal/pipelines/plc-1");
fireEvent.click(await screen.findByText("portal.pipelines.detail.delete"));
fireEvent.click(await screen.findByText("portal.pipelines.delete.confirm"));
await waitFor(() => expect(deletePipeline).toHaveBeenCalledWith("plc-1"));
expect(await screen.findByText("pipelines list")).toBeInTheDocument();
});
it("prompts to save or discard when leaving with unsaved edits", async () => {
renderBuilder("/portal/pipelines/new");
fireEvent.change(await screen.findByRole("textbox"), {
target: { value: "Draft" },
});
fireEvent.click(screen.getByText("portal.pipelines.composer.cancel"));
expect(
await screen.findByText("portal.pipelines.builder.unsavedTitle"),
).toBeInTheDocument();
fireEvent.click(screen.getByText("portal.pipelines.builder.discard"));
expect(await screen.findByText("pipelines list")).toBeInTheDocument();
});
it("leaves immediately when there are no unsaved edits", async () => {
renderBuilder("/portal/pipelines/new");
await screen.findByRole("textbox");
fireEvent.click(screen.getByText("portal.pipelines.composer.cancel"));
expect(await screen.findByText("pipelines list")).toBeInTheDocument();
});
});
@@ -0,0 +1,865 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
import KeyboardArrowUpRoundedIcon from "@mui/icons-material/KeyboardArrowUpRounded";
import KeyboardArrowDownRoundedIcon from "@mui/icons-material/KeyboardArrowDownRounded";
import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded";
import AddRoundedIcon from "@mui/icons-material/AddRounded";
import PlayArrowRoundedIcon from "@mui/icons-material/PlayArrowRounded";
import {
ActionIcon,
Banner,
Button,
Checkbox,
EmptyState,
FormField,
Input,
Modal,
RadioGroup,
Select,
Spinner,
} from "@app/ui";
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
import { type ErasedToolParams } from "@app/hooks/tools/shared/toolOperationTypes";
import {
deserializeToolStep,
getExecutableTools,
newWorkingToolStep,
serializeToolStep,
stepRequiresUpload,
type ExecutableTool,
type WorkingToolStep,
} from "@app/hooks/tools/shared/toolAutomation";
import { errorMessage } from "@portal/api/http";
import {
deletePipeline,
fetchPipeline,
fetchRun,
fetchTriggers,
savePipeline,
triggerPipeline,
type OutputSpec,
type Policy,
type PolicyRunView,
type TriggerConfig,
type TriggerInfo,
} from "@portal/api/pipelines";
import { fetchSources, type SourceView } from "@portal/api/sources";
import { useAsync } from "@portal/hooks/useAsync";
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
import { humanizeOperation } from "@portal/components/pipelines/pipelineOperations";
import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings";
import { ToolPicker } from "@portal/components/pipelines/ToolPicker";
import "@portal/views/PipelineBuilder.css";
type OutputMode = "inline" | "folder";
type ScheduleUnit = "MINUTES" | "HOURS" | "DAYS";
const SCHEDULE_UNITS: ScheduleUnit[] = ["MINUTES", "HOURS", "DAYS"];
/** Empty trigger type = manual-only (no automatic trigger). */
const MANUAL = "";
const TERMINAL_STATUSES = new Set(["COMPLETED", "FAILED", "CANCELLED"]);
const POLL_INTERVAL_MS = 1500;
const POLL_ATTEMPTS = 60;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
type RunResult = {
tone: "success" | "danger" | "info" | "warning";
text: string;
};
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",
};
}
return { triggerType: "schedule", count: "1", unit: "HOURS" };
}
return { triggerType: trigger.type, count: "1", unit: "HOURS" };
}
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: "" };
}
/**
* Full-page pipeline builder (route: /pipelines/new and /pipelines/:id). Pipeline-level settings
* (sources, trigger, output) sit above the operation list; the operation list and the selected
* tool's settings sit side by side below. For an existing pipeline the header also runs and
* deletes it. Replaces the former modal composer and the list's inline detail card.
*/
export function PipelineBuilder() {
const { t } = useTranslation();
const navigate = useNavigate();
const { id } = useParams();
const isEdit = Boolean(id);
const { allTools } = useToolRegistry();
const executableTools = useMemo(
() => getExecutableTools(allTools),
[allTools],
);
const policyState = useAsync<Policy | null>(
async () => (id ? await fetchPipeline(id) : null),
[id],
);
const sourcesState = useAsync<SourceView[]>(
async () => (await fetchSources()).sources,
[],
);
const triggersState = useAsync<TriggerInfo[]>(
async () => await fetchTriggers(),
[],
);
const availableSources = sourcesState.data ?? [];
const triggers = useMemo(
() => triggersState.data ?? [],
[triggersState.data],
);
const [name, setName] = useState("");
const [enabled, setEnabled] = useState(true);
const [sourceIds, setSourceIds] = useState<string[]>([]);
const [steps, setSteps] = useState<WorkingToolStep[]>([]);
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [triggerType, setTriggerType] = useState<string>(MANUAL);
const [scheduleCount, setScheduleCount] = useState("1");
const [scheduleUnit, setScheduleUnit] = useState<ScheduleUnit>("HOURS");
const [outputMode, setOutputMode] = useState<OutputMode>("inline");
const [outputDirectory, setOutputDirectory] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [seeded, setSeeded] = useState(false);
const [running, setRunning] = useState(false);
const [runResult, setRunResult] = useState<RunResult | null>(null);
const [pendingDelete, setPendingDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
const [pendingNav, setPendingNav] = useState<string | null>(null);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
// Seed the form once: immediately for a new pipeline, or after the policy loads for an edit.
useEffect(() => {
if (seeded) return;
if (isEdit && !policyState.data) return;
const policy = policyState.data ?? undefined;
const trigger = parseTrigger(policy?.trigger ?? null);
const output = parseOutput(policy?.output);
setName(policy?.name ?? "");
setEnabled(policy?.enabled ?? true);
setSourceIds(policy?.sourceIds ?? []);
setSteps(
(policy?.steps ?? []).map((step) => deserializeToolStep(step, allTools)),
);
setTriggerType(trigger.triggerType);
setScheduleCount(trigger.count);
setScheduleUnit(trigger.unit);
setOutputMode(output.mode);
setOutputDirectory(output.directory);
setSeeded(true);
}, [isEdit, policyState.data, allTools, seeded]);
// Keep one tool's settings open: auto-select the first step whenever a pipeline has steps but
// nothing is selected (initial load, or after the selected step is removed).
useEffect(() => {
if (seeded && selectedIndex === null && steps.length > 0) {
setSelectedIndex(0);
}
}, [seeded, selectedIndex, steps.length]);
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],
);
useEffect(() => {
if (triggerType === MANUAL) return;
const selected = triggers.find((trigger) => trigger.type === triggerType);
if (selected && !triggerAvailable(selected)) setTriggerType(MANUAL);
}, [triggerType, triggers, triggerAvailable]);
function toggleSource(sourceId: string, checked: boolean) {
setSourceIds((ids) =>
checked
? [...ids, sourceId]
: ids.filter((existing) => existing !== sourceId),
);
}
function addStep(tool: ExecutableTool) {
setSteps((current) => {
const next = [...current, newWorkingToolStep(tool, allTools)];
setSelectedIndex(next.length - 1);
return next;
});
setPickerOpen(false);
}
function removeStep(index: number) {
setSelectedIndex(null);
setSteps((current) => current.filter((_, i) => i !== index));
}
function moveStep(index: number, delta: number) {
setSteps((current) => {
const target = index + delta;
if (target < 0 || target >= current.length) return current;
const next = [...current];
[next[index], next[target]] = [next[target], next[index]];
return next;
});
setSelectedIndex((cur) => (cur === index ? index + delta : cur));
}
function updateStepParams(index: number, params: ErasedToolParams) {
setSteps((current) =>
current.map((step, i) =>
i === index && step.toolId !== null ? { ...step, params } : step,
),
);
}
function stepLabel(step: WorkingToolStep): string {
const entry = step.toolId ? allTools[step.toolId] : undefined;
return entry?.name ?? humanizeOperation(step.operation);
}
// Steps whose params carry an uploaded file can't be saved: the bytes aren't persisted with the
// policy, so a later run would send null for that field (see stepRequiresUpload).
const uploadStepLabels = steps.filter(stepRequiresUpload).map(stepLabel);
const hasUploadSteps = uploadStepLabels.length > 0;
// Track unsaved edits: snapshot the form and compare against the state captured just after
// seeding, so leaving the builder can prompt to save or discard.
const snapshot = JSON.stringify({
name: name.trim(),
enabled,
sourceIds: [...sourceIds].sort(),
steps: steps.map((step) => serializeToolStep(step, allTools)),
uploads: steps.map(stepRequiresUpload),
triggerType,
scheduleCount,
scheduleUnit,
outputMode,
outputDirectory,
});
const baseline = useRef<string | null>(null);
useEffect(() => {
if (seeded && baseline.current === null) baseline.current = snapshot;
}, [seeded, snapshot]);
const dirty = baseline.current !== null && baseline.current !== snapshot;
const scheduleCountValid =
triggerType !== "schedule" || Number(scheduleCount) > 0;
const outputValid = outputMode !== "folder" || outputDirectory.trim() !== "";
const canSave =
name.trim() !== "" &&
scheduleCountValid &&
outputValid &&
!hasUploadSteps &&
!submitting;
const triggerOptions = [
{ value: MANUAL, label: t("portal.pipelines.composer.triggerManual") },
...triggers.map((trigger) => ({
value: trigger.type,
label: t(`portal.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: {} };
}
const listPath = toPortalPath(VIEW_PATHS.pipelines);
const sourcesPath = `${toPortalPath(VIEW_PATHS.sources)}?new=1`;
function close() {
navigate(listPath);
}
// Leave the builder, but prompt first if there are unsaved edits (see the unsaved-changes modal).
function attemptLeave(destination: string) {
if (dirty) setPendingNav(destination);
else navigate(destination);
}
// Jump to the Sources page with its create wizard open, for when the source you want to run
// this pipeline over doesn't exist yet.
function goToSources() {
attemptLeave(sourcesPath);
}
async function save(destination: string) {
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: policyState.data?.id ?? undefined,
name: name.trim(),
enabled,
trigger: buildTrigger(),
sourceIds,
steps: steps.map((step) => serializeToolStep(step, allTools)),
output,
};
try {
await savePipeline(policy);
navigate(destination);
} catch (e) {
setError(errorMessage(e));
setSubmitting(false);
}
}
// Poll a run until it reaches a terminal state (or we give up), so a failure surfaces.
async function awaitRun(runId: string): Promise<PolicyRunView | null> {
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 || !id) return;
setRunning(true);
setRunResult(null);
try {
const runIds = await triggerPipeline(id);
if (runIds.length === 0) {
if (mounted.current)
setRunResult({ tone: "info", text: t("portal.pipelines.run.empty") });
return;
}
const finals = await Promise.all(runIds.map((runId) => awaitRun(runId)));
if (!mounted.current) return;
const failed = finals.find((r) => r?.status === "FAILED");
if (failed) {
setRunResult({
tone: "danger",
text: t("portal.pipelines.run.failed", { error: failed.error ?? "" }),
});
} else if (finals.some((r) => r === null)) {
// Gave up polling before a terminal status; the run may still finish server-side.
setRunResult({
tone: "warning",
text: t("portal.pipelines.run.timeout"),
});
} else if (finals.every((r) => r?.status === "COMPLETED")) {
setRunResult({
tone: "success",
text: t("portal.pipelines.run.completed", { count: finals.length }),
});
} else {
setRunResult({ tone: "info", text: t("portal.pipelines.run.running") });
}
} catch (e) {
if (mounted.current)
setRunResult({ tone: "danger", text: errorMessage(e) });
} finally {
if (mounted.current) setRunning(false);
}
}
async function confirmDelete() {
if (!id || deleting) return;
setDeleting(true);
try {
await deletePipeline(id);
close();
} catch (e) {
setError(errorMessage(e));
setDeleting(false);
setPendingDelete(false);
}
}
if (isEdit && !seeded) {
return (
<div className="portal-builder__loading">
<Spinner />
</div>
);
}
const selectedStep =
selectedIndex !== null ? (steps[selectedIndex] ?? null) : null;
return (
<div className="portal-builder">
<header className="portal-builder__head">
<Button
variant="quiet"
size="sm"
className="portal-builder__back"
onClick={() => attemptLeave(listPath)}
aria-label={t("portal.pipelines.builder.back")}
leftSection={
<ArrowBackRoundedIcon style={{ fontSize: "1.125rem" }} />
}
>
{t("portal.pipelines.title")}
</Button>
<div className="portal-builder__head-main">
<Input
value={name}
placeholder={t("portal.pipelines.composer.namePlaceholder")}
aria-label={t("portal.pipelines.composer.name")}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="portal-builder__head-actions">
<Checkbox
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
label={t("portal.pipelines.builder.enabled")}
/>
{isEdit && (
<>
<Button
variant="secondary"
size="sm"
loading={running}
onClick={handleRun}
leftSection={
<PlayArrowRoundedIcon style={{ fontSize: "1.125rem" }} />
}
>
{t("portal.pipelines.detail.run")}
</Button>
<Button
variant="secondary"
size="sm"
accent="danger"
onClick={() => setPendingDelete(true)}
leftSection={
<DeleteOutlineRoundedIcon style={{ fontSize: "1.125rem" }} />
}
>
{t("portal.pipelines.detail.delete")}
</Button>
</>
)}
<Button
variant="tertiary"
size="sm"
onClick={() => attemptLeave(listPath)}
disabled={submitting}
>
{t("portal.pipelines.composer.cancel")}
</Button>
<Button
size="sm"
onClick={() => save(listPath)}
loading={submitting}
disabled={!canSave}
>
{isEdit
? t("portal.pipelines.composer.save")
: t("portal.pipelines.composer.create")}
</Button>
</div>
</header>
{error && <Banner tone="danger" description={error} />}
{runResult && (
<Banner tone={runResult.tone} description={runResult.text} />
)}
{hasUploadSteps && (
<Banner
tone="warning"
description={t("portal.pipelines.builder.uploadUnsupported", {
tools: uploadStepLabels.join(", "),
})}
/>
)}
{/* Pipeline-level settings, above the operation list. */}
<section className="portal-builder__settings">
<div className="portal-builder__section-label">
{t("portal.pipelines.builder.pipelineSettings")}
</div>
<div className="portal-builder__settings-grid">
<div className="portal-builder__settings-col">
<span className="portal-pipelines__detail-heading">
{t("portal.pipelines.composer.sources")}
</span>
{sourcesState.loading ? (
<p className="portal-pipelines__muted">
{t("portal.pipelines.composer.sourcesLoading")}
</p>
) : availableSources.length === 0 ? (
<p className="portal-pipelines__muted">
{t("portal.pipelines.composer.noSources")}
</p>
) : (
<div className="portal-pipelines__source-list">
{availableSources.map((source) => (
<Checkbox
key={source.id}
checked={sourceIds.includes(source.id)}
onChange={(e) => toggleSource(source.id, e.target.checked)}
label={source.name}
/>
))}
</div>
)}
<Button
variant="tertiary"
size="sm"
onClick={goToSources}
leftSection={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}
>
{t("portal.sources.actions.connectSource")}
</Button>
</div>
<div className="portal-builder__settings-col">
<span className="portal-pipelines__detail-heading">
{t("portal.pipelines.composer.trigger")}
</span>
<RadioGroup<string>
name="pipeline-trigger"
value={triggerType}
onChange={setTriggerType}
options={triggerOptions}
/>
{triggerType === "schedule" && (
<div className="portal-pipelines__schedule">
<span className="portal-pipelines__muted">
{t("portal.pipelines.composer.scheduleEvery")}
</span>
<Input
inputSize="sm"
type="number"
min={1}
value={scheduleCount}
invalid={!scheduleCountValid}
onChange={(e) => setScheduleCount(e.target.value)}
className="portal-pipelines__schedule-count"
/>
<Select
inputSize="sm"
value={scheduleUnit}
onChange={(value) =>
value && setScheduleUnit(value as ScheduleUnit)
}
options={SCHEDULE_UNITS.map((unit) => ({
value: unit,
label: t(
`portal.pipelines.composer.unit.${unit.toLowerCase()}`,
),
}))}
/>
</div>
)}
</div>
<div className="portal-builder__settings-col">
<span className="portal-pipelines__detail-heading">
{t("portal.pipelines.composer.output")}
</span>
<RadioGroup<OutputMode>
name="pipeline-output"
value={outputMode}
onChange={setOutputMode}
options={[
{ value: "inline", label: t("portal.pipelines.output.inline") },
{ value: "folder", label: t("portal.pipelines.output.folder") },
]}
/>
{outputMode === "folder" && (
<FormField
label={t("portal.pipelines.composer.directory")}
helperText={t("portal.pipelines.composer.directoryHelp")}
required
>
<Input
value={outputDirectory}
placeholder="/data/processed"
onChange={(e) => setOutputDirectory(e.target.value)}
/>
</FormField>
)}
</div>
</div>
</section>
<div className="portal-builder__grid">
<section className="portal-builder__flow">
<div className="portal-builder__section-label">
{t("portal.pipelines.composer.operations", { count: steps.length })}
</div>
{steps.length === 0 && !pickerOpen && (
<p className="portal-builder__empty">
{t("portal.pipelines.composer.chainEmpty")}
</p>
)}
<ol className="portal-builder__steps">
{steps.map((step, i) => (
<li key={`${step.operation}-${i}`}>
<div
className={
"portal-builder__step" +
(selectedIndex === i ? " portal-builder__step--active" : "")
}
>
<Button
variant="quiet"
justify="start"
className="portal-builder__step-main"
onClick={() => setSelectedIndex(i)}
leftSection={
<span className="portal-builder__step-index">
{i + 1}
</span>
}
>
<span className="portal-builder__step-text">
<span className="portal-builder__step-name">
{stepLabel(step)}
</span>
{stepRequiresUpload(step) ? (
<span className="portal-builder__step-note">
{t("portal.pipelines.builder.needsUpload")}
</span>
) : step.support === "unsupported" ? (
<span className="portal-builder__step-note">
{t("portal.pipelines.builder.usesDefaults")}
</span>
) : step.support === "unknown" ? (
<span className="portal-builder__step-note">
{t("portal.pipelines.builder.unknownStep")}
</span>
) : null}
</span>
</Button>
<div className="portal-builder__step-actions">
<ActionIcon
variant="tertiary"
aria-label={t("portal.pipelines.composer.moveUp")}
disabled={i === 0}
onClick={() => moveStep(i, -1)}
>
<KeyboardArrowUpRoundedIcon
style={{ fontSize: "1.125rem" }}
/>
</ActionIcon>
<ActionIcon
variant="tertiary"
aria-label={t("portal.pipelines.composer.moveDown")}
disabled={i === steps.length - 1}
onClick={() => moveStep(i, 1)}
>
<KeyboardArrowDownRoundedIcon
style={{ fontSize: "1.125rem" }}
/>
</ActionIcon>
<ActionIcon
variant="tertiary"
aria-label={t("portal.pipelines.composer.removeStep")}
onClick={() => removeStep(i)}
>
<DeleteOutlineRoundedIcon
style={{ fontSize: "1.125rem" }}
/>
</ActionIcon>
</div>
</div>
</li>
))}
</ol>
{pickerOpen ? (
<ToolPicker
tools={executableTools}
onPick={addStep}
onClose={() => setPickerOpen(false)}
/>
) : (
<Button
variant="quiet"
fullWidth
className="portal-builder__add-step"
onClick={() => setPickerOpen(true)}
leftSection={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}
>
{t("portal.pipelines.composer.addTool")}
</Button>
)}
</section>
<aside className="portal-builder__inspector-col">
<div className="portal-builder__section-label">
{selectedStep
? stepLabel(selectedStep)
: t("portal.pipelines.builder.toolSettings")}
</div>
<div className="portal-builder__inspector">
{selectedStep ? (
<PipelineStepSettings
step={selectedStep}
registry={allTools}
onChange={(params) =>
selectedIndex !== null &&
updateStepParams(selectedIndex, params)
}
/>
) : (
<EmptyState
title={t("portal.pipelines.builder.selectToolTitle")}
description={t("portal.pipelines.builder.selectToolBody")}
/>
)}
</div>
</aside>
</div>
<Modal
open={pendingDelete}
onClose={() => !deleting && setPendingDelete(false)}
width="sm"
title={t("portal.pipelines.delete.title")}
footer={
<div className="portal-pipelines__composer-footer">
<Button
variant="tertiary"
size="sm"
disabled={deleting}
onClick={() => setPendingDelete(false)}
>
{t("portal.pipelines.delete.cancel")}
</Button>
<Button
size="sm"
accent="danger"
loading={deleting}
onClick={confirmDelete}
>
{t("portal.pipelines.delete.confirm")}
</Button>
</div>
}
>
<p>{t("portal.pipelines.delete.body", { name: name || "" })}</p>
</Modal>
<Modal
open={pendingNav !== null}
onClose={() => !submitting && setPendingNav(null)}
width="sm"
title={t("portal.pipelines.builder.unsavedTitle")}
footer={
<div className="portal-pipelines__composer-footer">
<Button
variant="tertiary"
size="sm"
disabled={submitting}
onClick={() => setPendingNav(null)}
>
{t("portal.pipelines.builder.keepEditing")}
</Button>
<div style={{ display: "flex", gap: "0.5rem" }}>
<Button
variant="secondary"
size="sm"
accent="danger"
disabled={submitting}
onClick={() => {
const target = pendingNav;
setPendingNav(null);
if (target) navigate(target);
}}
>
{t("portal.pipelines.builder.discard")}
</Button>
<Button
size="sm"
loading={submitting}
disabled={!canSave}
onClick={() => {
const target = pendingNav;
setPendingNav(null);
if (target) void save(target);
}}
>
{t("portal.pipelines.composer.save")}
</Button>
</div>
</div>
}
>
<p>{t("portal.pipelines.builder.unsavedBody")}</p>
</Modal>
</div>
);
}
+7 -189
View File
@@ -81,104 +81,6 @@
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;
align-items: center;
gap: 0.75rem;
padding-bottom: 0.875rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--color-border-light);
}
.portal-pipelines__expanded-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-pipelines__expanded-sub {
font-size: 0.75rem;
color: var(--color-text-4);
}
.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__expanded-close:hover {
background: var(--color-bg-hover);
color: var(--color-text-1);
}
/* Detail body */
.portal-pipelines__detail {
display: flex;
flex-direction: column;
gap: 1.125rem;
}
.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;
@@ -186,20 +88,13 @@
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;
/* Shared with the pipeline builder page. */
.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__composer-footer {
@@ -221,83 +116,6 @@
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;
@@ -3,22 +3,18 @@ import {
fireEvent,
render as baseRender,
screen,
waitFor,
} from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { MemoryRouter } from "react-router-dom";
import { HttpError } from "@portal/api/http";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import type { PipelinesOverviewResponse } from "@portal/api/pipelines";
import { Pipelines } from "@portal/views/Pipelines";
const render = (
ui: Parameters<typeof baseRender>[0],
options?: Parameters<typeof baseRender>[1],
) => baseRender(ui, { wrapper: MantineProvider, ...options });
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.
// Deterministic i18n: keys returned verbatim.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
@@ -27,25 +23,8 @@ vi.mock("react-i18next", () => ({
}));
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 = {
@@ -66,55 +45,20 @@ const RESPONSE: PipelinesOverviewResponse = {
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: 0,
docs24h: 0,
docs30d: 0,
},
],
};
function renderView() {
function renderView(initial = "/portal/pipelines") {
return render(
<MemoryRouter>
<Pipelines />
<MemoryRouter initialEntries={[initial]}>
<Routes>
<Route path="/portal/pipelines" element={<Pipelines />} />
<Route path="/portal/pipelines/new" element={<div>builder new</div>} />
<Route
path="/portal/pipelines/:id"
element={<div>pipeline page</div>}
/>
</Routes>
</MemoryRouter>,
);
}
@@ -122,139 +66,19 @@ function renderView() {
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([]);
fetchPipelines.mockResolvedValue(RESPONSE);
});
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",
}),
);
it("opens the builder when creating a pipeline", async () => {
renderView();
fireEvent.click(await screen.findByText("Redaction sweep"));
fireEvent.click(await screen.findByText("portal.pipelines.detail.delete"));
fireEvent.click(await screen.findByText("portal.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("portal.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("portal.pipelines.detail.run"));
await waitFor(() => {
expect(triggerPipeline).toHaveBeenCalledWith("plc-redaction");
});
expect(
await screen.findByText("portal.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("portal.pipelines.detail.run"));
expect(
await screen.findByText("portal.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("portal.pipelines.actions.newPipeline"));
expect(await screen.findByText("builder new")).toBeInTheDocument();
});
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("portal.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" }),
],
}),
);
it("opens a pipeline's own page when its row is clicked", async () => {
renderView();
fireEvent.click(await screen.findByText("Redaction sweep"));
expect(await screen.findByText("pipeline page")).toBeInTheDocument();
});
});
+16 -148
View File
@@ -1,107 +1,32 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Banner, Button, EmptyState, Modal, Skeleton } from "@app/ui";
import { useNavigate } from "react-router-dom";
import AddRoundedIcon from "@mui/icons-material/AddRounded";
import { Button, EmptyState, Skeleton } from "@app/ui";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import { errorMessage } from "@portal/api/http";
import {
deletePipeline,
fetchPipeline,
fetchPipelines,
savePipeline,
type PipelinesOverviewResponse,
type PipelineView,
type Policy,
} from "@portal/api/pipelines";
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
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 "@portal/views/Pipelines.css";
export function Pipelines() {
const { t } = useTranslation();
// 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<PipelinesOverviewResponse>(
() => fetchPipelines(),
[version],
);
const navigate = useNavigate();
const state = useAsync<PipelinesOverviewResponse>(() => fetchPipelines(), []);
const { data, loading } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
const refetch = useCallback(() => setVersion((v) => v + 1), []);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [composerOpen, setComposerOpen] = useState(false);
const [editing, setEditing] = useState<Policy | null>(null);
const [mutating, setMutating] = useState(false);
const [pageError, setPageError] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<PipelineView | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const pipelines = data?.pipelines ?? [];
const expanded = pipelines.find((p) => p.id === expandedId) ?? null;
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);
}
}
const openCreate = () =>
navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/new`);
// A row opens that pipeline's own page (view / edit / run / delete live there).
const openPipeline = (pipeline: PipelineView) =>
navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/${pipeline.id}`);
return (
<div className="portal-pipelines">
@@ -114,13 +39,14 @@ export function Pipelines() {
{t("portal.pipelines.subtitle")}
</p>
</div>
<Button onClick={openCreate} leftSection={<span aria-hidden>+</span>}>
<Button
onClick={openCreate}
leftSection={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}
>
{t("portal.pipelines.actions.newPipeline")}
</Button>
</header>
{pageError && <Banner tone="danger" description={pageError} />}
<KpiStrip data={data} loading={loading} />
{isLoading && (
@@ -144,66 +70,8 @@ export function Pipelines() {
)}
{!isLoading && !isEmpty && pipelines.length > 0 && (
<PipelinesTable
pipelines={pipelines}
expandedId={expandedId}
onRowClick={(p) =>
setExpandedId((cur) => (cur === p.id ? null : p.id))
}
/>
<PipelinesTable pipelines={pipelines} onRowClick={openPipeline} />
)}
{expanded && (
<PipelineDetailCard
pipeline={expanded}
onClose={() => setExpandedId(null)}
onEdit={openEdit}
onTogglePause={togglePause}
onDelete={requestDelete}
busy={mutating}
/>
)}
<PipelineComposer
open={composerOpen}
pipeline={editing ?? undefined}
onClose={() => setComposerOpen(false)}
onSaved={refetch}
/>
<Modal
open={pendingDelete !== null}
onClose={() => !deleting && setPendingDelete(null)}
width="sm"
title={t("portal.pipelines.delete.title")}
footer={
<div className="portal-pipelines__composer-footer">
<Button
variant="tertiary"
size="sm"
disabled={deleting}
onClick={() => setPendingDelete(null)}
>
{t("portal.pipelines.delete.cancel")}
</Button>
<Button
size="sm"
accent="danger"
loading={deleting}
onClick={confirmDelete}
>
{t("portal.pipelines.delete.confirm")}
</Button>
</div>
}
>
<p>
{t("portal.pipelines.delete.body", {
name: pendingDelete?.name ?? "",
})}
</p>
{deleteError && <Banner tone="danger" description={deleteError} />}
</Modal>
</div>
);
}
+14 -1
View File
@@ -1,4 +1,5 @@
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Banner, Button, EmptyState, Modal, Skeleton } from "@app/ui";
import { useView } from "@portal/contexts/ViewContext";
@@ -24,6 +25,7 @@ import "@portal/views/Sources.css";
export function Sources() {
const { t } = useTranslation();
const { setActiveView } = useView();
const [searchParams, setSearchParams] = useSearchParams();
// Refetch after every mutation by bumping this counter, so the table reflects
// the in-memory store the handlers maintain (mirrors the Policies view).
const [version, setVersion] = useState(0);
@@ -64,6 +66,17 @@ export function Sources() {
setWizardOpen(true);
}
// Arriving with ?new (e.g. from the pipeline builder's "connect a source" link) opens the
// create wizard straight away, then strips the flag so a refresh doesn't reopen it.
useEffect(() => {
if (searchParams.get("new") === null) return;
setEditingSource(null);
setWizardOpen(true);
const next = new URLSearchParams(searchParams);
next.delete("new");
setSearchParams(next, { replace: true });
}, [searchParams, setSearchParams]);
// Editing needs the raw source (config options), which the overview rows don't
// carry, so fetch it before opening the wizard prefilled.
async function openEdit(source: SourceView) {