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.  ### Pipeline builder The dedicated create page: pipeline settings (sources, trigger, output) above, operations and per-tool settings below.  ### Tool picker Type-to-filter, category-grouped picker for adding an operation to the pipeline.  ### Editing a pipeline An existing pipeline in the builder: reorderable steps, per-tool settings, and run/delete actions. 
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user