Add pipelines page to portal (#6818)

# Description of Changes

Connect pipelines page to the backend. Note that this is really half an
implementation because the portal doesn't have access to the tools list
and their settings, but I can't fix that without re-architecture work,
which I'll do in another PR, then come back to finish this off in a new
PR.

<img width="786" height="579" alt="image"
src="https://github.com/user-attachments/assets/d3f06110-a35d-4d48-a2f9-1edb900c5c35"
/>

<img width="1232" height="519" alt="image"
src="https://github.com/user-attachments/assets/9f344648-ea45-498d-9e84-9558a3999838"
/>
This commit is contained in:
James Brunton
2026-06-30 16:11:48 +00:00
committed by GitHub
parent e44da5c410
commit 276eb8f2a7
37 changed files with 2444 additions and 2436 deletions
@@ -2,6 +2,7 @@ package stirling.software.proprietary.policy.controller;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -52,11 +53,15 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.model.PolicyRun;
import stirling.software.proprietary.policy.model.PolicyRunStatus;
import stirling.software.proprietary.policy.model.PolicyRunView;
import stirling.software.proprietary.policy.overview.PoliciesOverviewResponse;
import stirling.software.proprietary.policy.overview.PolicyOverviewService;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import stirling.software.proprietary.policy.source.SourceAccessGuard;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
import stirling.software.proprietary.policy.trigger.PolicyTrigger;
import stirling.software.proprietary.policy.trigger.PolicyTriggerManager;
import stirling.software.proprietary.policy.trigger.TriggerInfo;
/**
* Policy CRUD plus pipeline runs (stored or ad-hoc). Runs are async: returns a run id, poll {@code
@@ -80,6 +85,8 @@ public class PolicyController {
private final PolicyAccessGuard policyAccessGuard;
private final PolicyManagementAuthority policyManagementAuthority;
private final PolicyTriggerManager policyTriggerManager;
private final PolicyOverviewService policyOverviewService;
private final List<PolicyTrigger> policyTriggers;
private final ApplicationProperties applicationProperties;
private final TempFileManager tempFileManager;
private final JobOwnershipService jobOwnershipService;
@@ -287,6 +294,31 @@ public class PolicyController {
return policyAccessGuard.visibleFrom(policyStore);
}
@GetMapping("/overview")
@Operation(
summary = "Pipelines overview",
description =
"Returns the KPI strip plus one row per policy the caller's team owns, each with"
+ " its referenced sources resolved to names, its pipeline steps, and a"
+ " trigger/output summary. Backs the portal's all-pipelines surface.")
public PoliciesOverviewResponse overview() {
return policyOverviewService.overview();
}
@GetMapping("/triggers")
@Operation(
summary = "List available triggers",
description =
"Lists each trigger kind with whether it needs a source and which source types"
+ " it supports, so the UI can offer triggers and pair them with the"
+ " right sources.")
public List<TriggerInfo> triggers() {
return policyTriggers.stream()
.map(TriggerInfo::of)
.sorted(Comparator.comparing(TriggerInfo::type))
.toList();
}
@GetMapping("/{policyId}")
@Operation(summary = "Get a policy by id")
public ResponseEntity<Policy> getPolicy(@PathVariable String policyId) {
@@ -337,6 +369,26 @@ public class PolicyController {
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
}
@PostMapping("/{policyId}/trigger")
@Operation(
summary = "Run a stored policy against its sources",
description =
"Pulls the policy's configured sources and runs the pipeline now, regardless of"
+ " the enabled flag (which only gates automatic triggering). Returns"
+ " the ids of the runs started; poll the run-status endpoint for each."
+ " Empty when the sources yielded no work to do.")
public ResponseEntity<List<String>> trigger(@PathVariable String policyId) {
Policy policy =
policyStore
.get(policyId)
.filter(policyAccessGuard::canAccess)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.NOT_FOUND, "No policy: " + policyId));
return ResponseEntity.accepted().body(policyRunner.run(policy));
}
private static void requireRunnable(PipelineDefinition definition) {
if (definition.steps().isEmpty()) {
throw new ResponseStatusException(
@@ -1,6 +1,7 @@
package stirling.software.proprietary.policy.engine;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
@@ -41,14 +42,15 @@ public class PolicyRunner {
* Trigger entry point. Pulls every referenced source; each yielded unit becomes its own run so
* one failure does not affect the others. No sources means one run with no input (generator
* pipeline). Missing or disabled sources are skipped so one broken reference does not stop the
* rest.
* rest. Returns the ids of the runs it started (empty when sources yielded no work), so a
* manual trigger can report back which runs to follow.
*/
public void run(Policy policy) {
public List<String> run(Policy policy) {
List<String> sourceIds = policy.sourceIds();
if (sourceIds.isEmpty()) {
startRun(policy, PolicyInputs.of(List.of()), unused -> {});
return;
return List.of(startRun(policy, PolicyInputs.of(List.of()), unused -> {}));
}
List<String> runIds = new ArrayList<>();
for (String sourceId : sourceIds) {
Source source = sourceStore.get(sourceId).orElse(null);
if (source == null) {
@@ -63,8 +65,9 @@ public class PolicyRunner {
policy.id());
continue;
}
pullAndRun(policy, source.toInputSpec());
runIds.addAll(pullAndRun(policy, source.toInputSpec()));
}
return runIds;
}
/** Run a stored policy on caller-supplied files (e.g. manual upload), bypassing its sources. */
@@ -79,14 +82,14 @@ public class PolicyRunner {
return policyEngine.submit(definition, inputs, listener);
}
private void pullAndRun(Policy policy, InputSpec spec) {
private List<String> pullAndRun(Policy policy, InputSpec spec) {
InputSource source = sourceFor(spec);
if (source == null) {
log.warn(
"No input source for type '{}' (policy {}); skipping",
spec.type(),
policy.id());
return;
return List.of();
}
List<ResolvedInput> work;
try {
@@ -97,19 +100,22 @@ public class PolicyRunner {
spec.type(),
policy.id(),
e.getMessage());
return;
return List.of();
}
List<String> runIds = new ArrayList<>();
for (ResolvedInput unit : work) {
startRun(policy, unit.inputs(), unit.onComplete());
runIds.add(startRun(policy, unit.inputs(), unit.onComplete()));
}
return runIds;
}
private void startRun(Policy policy, PolicyInputs inputs, Consumer<Boolean> onComplete) {
private String startRun(Policy policy, PolicyInputs inputs, Consumer<Boolean> onComplete) {
log.info("Running policy {} ({})", policy.id(), policy.name());
PolicyRunHandle handle =
policyEngine.runPolicy(policy, inputs, PolicyProgressListener.NOOP);
handle.completion()
.whenComplete((run, throwable) -> onComplete.accept(succeeded(run, throwable)));
return handle.runId();
}
private static boolean succeeded(PolicyRun run, Throwable throwable) {
@@ -0,0 +1,6 @@
package stirling.software.proprietary.policy.overview;
import java.util.List;
/** The Pipelines overview payload: a KPI strip plus one row per policy. */
public record PoliciesOverviewResponse(List<PolicyKpi> kpis, List<PolicyView> pipelines) {}
@@ -0,0 +1,4 @@
package stirling.software.proprietary.policy.overview;
/** One headline figure in the Pipelines overview strip. */
public record PolicyKpi(long value, String description) {}
@@ -0,0 +1,102 @@
package stirling.software.proprietary.policy.overview;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.TriggerConfig;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceAccessGuard;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
* Builds the Pipelines overview: every policy the caller's team owns, each annotated with its
* referenced sources (resolved to display names), its pipeline steps, and a trigger/output summary.
* Source names are resolved from the team's sources in memory rather than persisted on the policy,
* so the view always reflects the live source set. This is the "all pipelines" admin surface; the
* user-facing Policies page builds only a friendly subset of the same backend policies.
*/
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyOverviewService {
private final PolicyStore policyStore;
private final SourceStore sourceStore;
private final PolicyAccessGuard policyAccessGuard;
private final SourceAccessGuard sourceAccessGuard;
public PoliciesOverviewResponse overview() {
List<Policy> policies = policyAccessGuard.visibleFrom(policyStore);
Map<String, String> sourceNames = sourceNames();
List<PolicyView> views =
policies.stream()
.map(policy -> toView(policy, sourceNames))
.sorted(
Comparator.comparing(
PolicyView::name, String.CASE_INSENSITIVE_ORDER))
.toList();
return new PoliciesOverviewResponse(buildKpis(policies), views);
}
/** Display names for every source the caller's team can see, keyed by source id. */
private Map<String, String> sourceNames() {
Map<String, String> names = new HashMap<>();
for (Source source : sourceAccessGuard.visibleFrom(sourceStore)) {
names.put(source.id(), source.name());
}
return names;
}
private static PolicyView toView(Policy policy, Map<String, String> sourceNames) {
List<PolicyView.SourceRef> sources =
policy.sourceIds().stream()
// An unresolved id (source deleted, or not visible) falls back to the id so
// the row still renders rather than dropping the reference silently.
.map(id -> new PolicyView.SourceRef(id, sourceNames.getOrDefault(id, id)))
.toList();
List<String> steps = policy.steps().stream().map(PipelineStep::operation).toList();
return new PolicyView(
policy.id(),
policy.name(),
policy.enabled(),
policy.enabled() ? "active" : "paused",
triggerSummary(policy.trigger()),
sources,
steps,
outputSummary(policy.output()),
policy.owner());
}
/** A null trigger is a manual-only policy; otherwise the trigger's type keys the summary. */
private static String triggerSummary(TriggerConfig trigger) {
return trigger == null ? "manual" : trigger.type();
}
private static String outputSummary(OutputSpec output) {
return output == null ? "inline" : output.type();
}
private static List<PolicyKpi> buildKpis(List<Policy> policies) {
long total = policies.size();
long active = policies.stream().filter(Policy::enabled).count();
long paused = total - active;
return List.of(
new PolicyKpi(total, "pipelines"),
new PolicyKpi(active, "running automatically"),
new PolicyKpi(paused, "paused"));
}
}
@@ -0,0 +1,24 @@
package stirling.software.proprietary.policy.overview;
import java.util.List;
/**
* One row in the Pipelines overview: a stored policy shown for the admin portal, with its
* referenced sources resolved to names and its pipeline summarised. The portal's "all pipelines"
* surface lists every backend policy (the user-facing Policies page builds only a friendly subset
* of these).
*/
public record PolicyView(
String id,
String name,
boolean enabled,
String status,
String trigger,
List<SourceRef> sources,
List<String> steps,
String output,
String owner) {
/** A source a policy pulls documents from, resolved to its display name. */
public record SourceRef(String id, String name) {}
}
@@ -27,6 +27,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.config.FolderAccessGuard;
import stirling.software.proprietary.policy.engine.PolicyRunner;
import stirling.software.proprietary.policy.input.InputSource;
import stirling.software.proprietary.policy.model.InputSpec;
@@ -74,6 +75,16 @@ public class FolderWatchTrigger implements PolicyTrigger {
return TYPE;
}
@Override
public boolean requiresSource() {
return true;
}
@Override
public Set<String> supportedSourceTypes() {
return Set.of(FolderAccessGuard.FOLDER_TYPE);
}
@Override
public void validate(Policy policy) {
if (watchDirsOf(policy).isEmpty()) {
@@ -1,5 +1,7 @@
package stirling.software.proprietary.policy.trigger;
import java.util.Set;
import stirling.software.proprietary.policy.model.Policy;
/**
@@ -11,6 +13,24 @@ public interface PolicyTrigger {
/** Matches {@code TriggerConfig.type()}. */
String type();
/**
* Whether this trigger needs at least one compatible input source to function. A schedule fires
* on the clock regardless of sources, so it is false; folder-watch derives the directories it
* watches from the policy's sources, so it is true. Drives whether the UI offers the trigger.
*/
default boolean requiresSource() {
return false;
}
/**
* The source {@code type()}s this trigger is compatible with (e.g. {@code "folder"}). Empty
* means source-agnostic (no constraint). Lets the UI offer a trigger only when a compatible
* source is selected, without hard-coding the relationship.
*/
default Set<String> supportedSourceTypes() {
return Set.of();
}
/**
* Validate at save time so misconfiguration fails fast, not at fire time. Receives the whole
* {@link Policy} so triggers that depend on the policy's sources (folder-watch) can check that.
@@ -0,0 +1,18 @@
package stirling.software.proprietary.policy.trigger;
import java.util.List;
/**
* Describes an available trigger for the admin UI: its {@code type} (matching {@code
* TriggerConfig.type()}), whether it needs a compatible source, and which source types it works
* with. Lets the UI list supported triggers and pair them with sources without hard-coding the set.
*/
public record TriggerInfo(String type, boolean requiresSource, List<String> supportedSourceTypes) {
public static TriggerInfo of(PolicyTrigger trigger) {
return new TriggerInfo(
trigger.type(),
trigger.requiresSource(),
List.copyOf(trigger.supportedSourceTypes()));
}
}
@@ -57,12 +57,23 @@ class PolicyControllerTest {
@Mock private PolicyAccessGuard policyAccessGuard;
@Mock private PolicyManagementAuthority policyManagementAuthority;
@Mock private PolicyTriggerManager policyTriggerManager;
@Mock
private stirling.software.proprietary.policy.overview.PolicyOverviewService
policyOverviewService;
@Mock private TempFileManager tempFileManager;
@Mock private JobOwnershipService jobOwnershipService;
private ApplicationProperties applicationProperties;
private PolicyController controller;
private final java.util.List<stirling.software.proprietary.policy.trigger.PolicyTrigger>
policyTriggers =
java.util.List.of(
trigger("schedule", false, java.util.Set.of()),
trigger("folder-watch", true, java.util.Set.of("folder")));
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
@@ -77,11 +88,33 @@ class PolicyControllerTest {
policyAccessGuard,
policyManagementAuthority,
policyTriggerManager,
policyOverviewService,
policyTriggers,
applicationProperties,
tempFileManager,
jobOwnershipService);
}
private static stirling.software.proprietary.policy.trigger.PolicyTrigger trigger(
String type, boolean requiresSource, java.util.Set<String> sourceTypes) {
return new stirling.software.proprietary.policy.trigger.PolicyTrigger() {
@Override
public String type() {
return type;
}
@Override
public boolean requiresSource() {
return requiresSource;
}
@Override
public java.util.Set<String> supportedSourceTypes() {
return sourceTypes;
}
};
}
private static PipelineDefinition definitionWithStep() {
return new PipelineDefinition(
"pipe", List.of(new PipelineStep("/api/v1/misc/compress-pdf", null)), null);
@@ -431,4 +464,50 @@ class PolicyControllerTest {
.isEqualTo(HttpStatus.NOT_FOUND));
}
}
@Nested
@DisplayName("triggers / trigger")
class Triggers {
@Test
@DisplayName("lists triggers sorted, with source compatibility")
void listsTriggers() {
List<stirling.software.proprietary.policy.trigger.TriggerInfo> infos =
controller.triggers();
assertThat(infos).extracting(t -> t.type()).containsExactly("folder-watch", "schedule");
stirling.software.proprietary.policy.trigger.TriggerInfo folderWatch = infos.get(0);
assertThat(folderWatch.requiresSource()).isTrue();
assertThat(folderWatch.supportedSourceTypes()).containsExactly("folder");
assertThat(infos.get(1).requiresSource()).isFalse();
assertThat(infos.get(1).supportedSourceTypes()).isEmpty();
}
@Test
@DisplayName("trigger runs an accessible policy against its sources and returns run ids")
void triggersRun() {
Policy p = policy("a", 1L);
when(policyStore.get("a")).thenReturn(Optional.of(p));
when(policyAccessGuard.canAccess(p)).thenReturn(true);
when(policyRunner.run(p)).thenReturn(List.of("run-a", "run-b"));
ResponseEntity<List<String>> response = controller.trigger("a");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
assertThat(response.getBody()).containsExactly("run-a", "run-b");
}
@Test
@DisplayName("trigger is 404 when the policy is inaccessible")
void triggerNotFound() {
when(policyStore.get("z")).thenReturn(Optional.empty());
assertThatThrownBy(() -> controller.trigger("z"))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
e ->
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.NOT_FOUND));
}
}
}
@@ -0,0 +1,186 @@
package stirling.software.proprietary.policy.overview;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.TriggerConfig;
import stirling.software.proprietary.policy.source.InProcessSourceStore;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceAccessGuard;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.InProcessPolicyStore;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
* Tests for {@link PolicyOverviewService}: every policy appears once with its sources resolved to
* names, its steps and trigger/output summarised, and the KPI strip counting active vs paused.
* Login is disabled so the team guards pass everything through.
*/
class PolicyOverviewServiceTest {
private final SourceStore sourceStore = new InProcessSourceStore();
private final PolicyStore policyStore = new InProcessPolicyStore();
private PolicyOverviewService service;
@BeforeEach
void setUp() {
ApplicationProperties properties = new ApplicationProperties();
properties.getSecurity().setEnableLogin(false);
UserServiceInterface userService = mock(UserServiceInterface.class);
PolicyManagementAuthority authority = mock(PolicyManagementAuthority.class);
SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority);
PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority);
service = new PolicyOverviewService(policyStore, sourceStore, policyGuard, sourceGuard);
}
@Test
void eachPolicyAppearsWithResolvedSourcesStepsAndSummary() {
Source claims = source("Claims intake", "/claims");
policyStore.save(
new Policy(
null,
"Redaction",
"owner",
true,
new TriggerConfig("schedule", Map.of()),
List.of(claims.id()),
List.of(new PipelineStep("/api/v1/security/auto-redact", Map.of())),
OutputSpec.inline()));
policyStore.save(
new Policy(
null,
"Archive (paused)",
"owner",
false,
null,
List.of(),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline()));
PoliciesOverviewResponse response = service.overview();
assertEquals(2, response.pipelines().size());
// Sorted by name, case-insensitive, so "Archive" leads "Redaction".
PolicyView archive = response.pipelines().get(0);
assertEquals("Archive (paused)", archive.name());
assertEquals("paused", archive.status());
assertEquals("manual", archive.trigger());
PolicyView redaction = find(response, "Redaction");
assertEquals("active", redaction.status());
assertEquals("schedule", redaction.trigger());
assertEquals("inline", redaction.output());
assertEquals(List.of("/api/v1/security/auto-redact"), redaction.steps());
assertEquals(1, redaction.sources().size());
assertEquals(claims.id(), redaction.sources().get(0).id());
assertEquals("Claims intake", redaction.sources().get(0).name());
// KPI strip: total, active, paused.
assertEquals(List.of(2L, 1L, 1L), response.kpis().stream().map(PolicyKpi::value).toList());
}
@Test
void anUnresolvedSourceFallsBackToItsId() {
policyStore.save(
new Policy(
null,
"Orphan",
"owner",
true,
null,
List.of("src-missing"),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline()));
PolicyView view = find(service.overview(), "Orphan");
assertEquals(1, view.sources().size());
assertEquals("src-missing", view.sources().get(0).id());
assertEquals("src-missing", view.sources().get(0).name());
}
@Test
void overviewLoadsOnlyTheCallersTeam() {
ApplicationProperties properties = new ApplicationProperties();
properties.getSecurity().setEnableLogin(true);
UserServiceInterface userService = mock(UserServiceInterface.class);
PolicyManagementAuthority authority = mock(PolicyManagementAuthority.class);
when(authority.currentUserTeamId()).thenReturn(1L);
SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority);
PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority);
PolicyOverviewService scoped =
new PolicyOverviewService(policyStore, sourceStore, policyGuard, sourceGuard);
Source ours = teamSource("Ours", "/ours", 1L);
teamPolicy("Our policy", 1L, ours.id());
teamPolicy("Their policy", 2L, ours.id());
PoliciesOverviewResponse response = scoped.overview();
assertEquals(1, response.pipelines().size());
PolicyView view = response.pipelines().get(0);
assertEquals("Our policy", view.name());
assertEquals("Ours", view.sources().get(0).name());
assertEquals(List.of(1L, 1L, 0L), response.kpis().stream().map(PolicyKpi::value).toList());
}
@Test
void emptyStoreReportsZeroKpis() {
PoliciesOverviewResponse response = service.overview();
assertTrue(response.pipelines().isEmpty());
assertEquals(List.of(0L, 0L, 0L), response.kpis().stream().map(PolicyKpi::value).toList());
}
private Source source(String name, String directory) {
return sourceStore.save(
new Source(
null, name, "folder", Map.of("directory", directory), true, "owner", null));
}
private Source teamSource(String name, String directory, Long teamId) {
return sourceStore.save(
new Source(
null,
name,
"folder",
Map.of("directory", directory),
true,
"owner",
teamId));
}
private void teamPolicy(String name, Long teamId, String... sourceIds) {
policyStore.save(
new Policy(
null,
name,
"owner",
true,
null,
List.of(sourceIds),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline(),
teamId));
}
private static PolicyView find(PoliciesOverviewResponse response, String name) {
return response.pipelines().stream()
.filter(view -> view.name().equals(name))
.findFirst()
.orElseThrow();
}
}
@@ -206,141 +206,96 @@ description = "The component catalogue could not be loaded. Try again shortly."
[pipelines]
title = "Pipelines"
subtitle = "Document workflows composed from typed operations — deployed, versioned, and continuously validated against a golden set."
subtitle = "Every automated document pipeline on the backend: an ordered chain of operations over a set of sources, run on a trigger. Click a row for its steps and sources."
[pipelines.actions]
newPipeline = "New pipeline"
[pipelines.kpi]
total = "Pipelines"
active = "Active"
paused = "Paused"
[pipelines.status]
healthy = "Healthy"
degraded = "Degraded"
active = "Active"
paused = "Paused"
[pipelines.fleet]
healthy_one = "{{count}} healthy"
healthy_other = "{{count}} healthy"
degraded_one = "{{count}} degraded"
degraded_other = "{{count}} degraded"
deployed_one = "{{count}} deployed"
deployed_other = "{{count}} deployed"
[pipelines.trigger]
manual = "Manual"
schedule = "Scheduled"
folder-watch = "Folder watch"
[pipelines.evals]
title = "Shadow + comparative evals active"
body_one = "{{count}} pipeline running a shadow eval, {{comparativeCount}} in a comparative run. {{detail}}"
body_other = "{{count}} pipelines running a shadow eval, {{comparativeCount}} in a comparative run. {{detail}}"
[pipelines.output]
inline = "Return files"
folder = "Write to folder"
[pipelines.empty]
title = "No pipelines yet"
description = "Compose your first document workflow from the typed operation library — pick a source, chain the ops, and route the output."
action = "Build your first pipeline"
[pipelines.reliability]
heading = "Golden-set reliability"
description = "Pass rate against each pipeline's golden set, judged against its own bound. Anything below bound shows amber or red."
[pipelines.promoted]
heading = "Promoted from the Editor"
description = "Watch-folder flows built in the Editor and promoted into the portal. Promote one to a policy to apply its rules fleet-wide."
policyCreated = "Policy created"
promoteToPolicy = "Promote to policy"
[pipelines.promoted.table]
sourceDocType = "Source doc type"
watchFolder = "Watch folder"
status = "Status"
[pipelines.promoted.status]
deployed = "Deployed"
staged = "Staged"
review = "Needs review"
[pipelines.table.header]
name = "Pipeline"
health = "Health"
goldenSet = "Golden set"
docs24h = "Docs / 24h"
version = "Version"
description = "Create your first pipeline: pick the sources it runs over, chain the operations, and choose where output goes."
action = "Create a pipeline"
[pipelines.table]
boundTooltip = "Bound: {{bound}}"
name = "Pipeline"
status = "Status"
steps = "Steps"
sources = "Sources"
[pipelines.metrics]
docs24h = "Docs / 24h"
throughput = "Throughput"
errorRate = "Error rate"
p95Latency = "P95 latency"
uptime = "Uptime"
[pipelines.detail]
subtitle = "{{trigger}} · {{status}}"
closeAriaLabel = "Close detail"
steps = "Operations"
noSteps = "No operations configured."
sources = "Sources"
noSources = "No sources. Files are supplied directly to each run."
output = "Output"
run = "Run now"
edit = "Edit"
pause = "Pause"
resume = "Resume"
delete = "Delete pipeline"
[pipelines.card]
stageTooltip_one = "{{label}}: {{count}} op"
stageTooltip_other = "{{label}}: {{count}} ops"
golden = "Golden {{passing}}/{{total}}"
drift_one = "{{count}} drift"
drift_other = "{{count}} drifts"
[pipelines.run]
empty = "Nothing to run: the sources had no documents to process."
failed = "Run failed: {{error}}"
running = "Run started; still in progress."
completed_one = "Run completed."
completed_other = "All {{count}} runs completed."
[pipelines.delete]
title = "Delete pipeline?"
body = "Delete \"{{name}}\"? This can't be undone."
cancel = "Cancel"
confirm = "Delete"
[pipelines.composer]
title = "New pipeline"
subtitle = "Pick a source, compose the operation chain, then route the output."
editTitle = "Edit pipeline"
subtitle = "Pick the sources it runs over, chain the operations, then choose when it runs and where output goes."
cancel = "Cancel"
back = "Back"
deploy = "Deploy pipeline"
continue = "Continue"
quickAddBundles = "Quick-add bundles"
chainEmpty = "Add operations from the library below."
operationChain_one = "Operation chain ({{count}})"
operationChain_other = "Operation chain ({{count}})"
destination = "Destination"
alerts = "Alerts"
create = "Create pipeline"
save = "Save changes"
name = "Name"
namePlaceholder = "e.g. Redaction sweep"
sources = "Sources"
sourcesLoading = "Loading sources..."
noSources = "No sources connected yet. The pipeline can still run on files supplied to it directly."
operations_one = "Operation ({{count}})"
operations_other = "Operations ({{count}})"
chainEmpty = "Add operations from the palette below."
moveUp = "Move up"
moveDown = "Move down"
removeStep = "Remove operation"
trigger = "Trigger"
triggerManual = "Manual only"
scheduleEvery = "Run every"
output = "Output"
directory = "Output folder"
directoryHelp = "Absolute path on the server. Must be within the configured allowed folders."
[pipelines.composer.steps]
source = "Source"
operations = "Operations"
routing = "Routing"
[pipelines.composer.anySource]
label = "Any source"
desc = "Accept documents from every connected channel"
[pipelines.composer.opKind]
ingest = "Ingest"
validate = "Validate"
modify = "Modify"
secure = "Secure"
store = "Route / Store"
alert = "Alerts"
[pipelines.composer.alert.email]
title = "Email on failure"
desc = "Notify the on-call list when error rate trips its bound"
[pipelines.composer.alert.webhook]
title = "Webhook on completion"
desc = "POST a run summary to a URL you control"
[pipelines.composer.alert.review]
title = "Route low-confidence to review"
desc = "Send docs under the confidence bound to a human queue"
[pipelines.detail]
subtitle = "{{version}} · {{source}} → {{destination}}"
[pipelines.detail.stages]
heading = "Pipeline stages"
description = "Every document flows through five stages between {{source}} and {{destination}}."
noOps = "No ops"
[pipelines.detail.golden]
heading = "Golden-set validation"
passing = "{{passing}} of {{total}} passing"
lastRun = "last run {{lastRun}}"
barLabel = "Golden set {{passing}} of {{total}} passing"
[pipelines.detail.drift]
heading = "Schema drift"
confidence = "{{delta}} conf"
docs_one = "{{count}} docs"
docs_other = "{{count}} docs"
[pipelines.detail.drift.empty]
title = "No drift detected"
description = "Every document in the last 24h matched its inferred schema."
[pipelines.composer.unit]
minutes = "minutes"
hours = "hours"
days = "days"
[sources]
title = "Sources"
+160 -29
View File
@@ -1,39 +1,170 @@
import { apiClient } from "@portal/api/http";
import type { PipelinesResponse } from "@portal/mocks/pipelines";
import type { Tier } from "@portal/contexts/TierContext";
export type {
EvalsNote,
GoldenSet,
Pipeline,
PipelineMetrics,
PipelinesResponse,
PipelineStatus,
PromotedPipeline,
PromotedStatus,
SchemaDrift,
StageKey,
StageSummary,
} from "@portal/mocks/pipelines";
/**
* Pipelines service layer: the backend contract.
*
* A "pipeline" in the portal IS a backend policy (PolicyController, Policy.java):
* an ordered chain of tool steps with input sources, a trigger, and an output
* destination. This surface lists EVERY backend policy (the user-facing Policies
* page builds only a friendly subset of the same records). Like Sources, it calls
* the REAL Stirling API base `/api/v1/policies`, so dropping MSW points these exact
* calls at the live backend.
*/
/** GET /v1/pipelines?tier=… — the deployed fleet plus tier-specific extras. */
export async function fetchPipelines(tier: Tier): Promise<PipelinesResponse> {
return apiClient.local.json<PipelinesResponse>(
`/v1/pipelines?tier=${encodeURIComponent(tier)}`,
);
/** One tool invocation in a pipeline. `operation` is a Stirling endpoint path. */
export interface PipelineStep {
operation: string;
parameters: Record<string, unknown>;
fileParameters?: Record<string, string>;
}
/** When a policy fires automatically. `type` keys a trigger bean (e.g. "schedule"). */
export interface TriggerConfig {
type: string;
options: Record<string, unknown>;
}
/** Where a run's outputs are delivered. `type` keys an output sink (e.g. "inline"). */
export interface OutputSpec {
type: string;
options: Record<string, unknown>;
}
/**
* Promote a watch-folder-derived pipeline into a governed org policy, so its
* rules apply fleet-wide instead of just to the originating flow.
*
* TODO(backend): POST /v1/pipelines/{id}/promote-to-policy — should create the
* policy from the pipeline's stages and return the new policy id. The mock
* handler resolves `{ ok: true }`; the UI treats a resolved promise as accepted.
* The stored policy record: the create/update body (`id` blank on create) and what
* the backend returns from GET/POST. Mirrors Policy.java exactly; `owner`/`teamId`
* are stamped server-side. A `null` trigger means manual-only.
*/
export async function promoteToPolicy(id: string): Promise<{ ok: true }> {
return apiClient.local.json<{ ok: true }>(
`/v1/pipelines/${encodeURIComponent(id)}/promote-to-policy`,
export interface Policy {
id?: string;
name: string;
owner?: string | null;
enabled: boolean;
trigger: TriggerConfig | null;
sourceIds: string[];
steps: PipelineStep[];
output: OutputSpec;
teamId?: number | null;
}
/** Overview row status: enabled (fires automatically) or paused. */
export type PipelineStatus = "active" | "paused";
/** A source a pipeline pulls documents from, resolved to its display name. */
export interface PipelineSourceRef {
id: string;
name: string;
}
/** One row in the Pipelines overview. Mirrors the backend `PolicyView`. */
export interface PipelineView {
id: string;
name: string;
enabled: boolean;
status: PipelineStatus;
/** Trigger summary: "manual" or the trigger type (e.g. "schedule"). */
trigger: string;
sources: PipelineSourceRef[];
/** Operation endpoint paths, in run order. */
steps: string[];
/** Output sink type (e.g. "inline", "folder"). */
output: string;
owner: string;
}
export interface PipelineKpi {
value: number;
description: string;
}
export interface PipelinesOverviewResponse {
kpis: PipelineKpi[];
pipelines: PipelineView[];
}
/** A trigger kind and the source types it works with. Mirrors the backend `TriggerInfo`. */
export interface TriggerInfo {
/** Matches `TriggerConfig.type` (e.g. "schedule", "folder-watch"). */
type: string;
/** Whether the trigger needs at least one compatible source to function. */
requiresSource: boolean;
/** Source types it supports; empty means source-agnostic (no constraint). */
supportedSourceTypes: string[];
}
export type PolicyRunStatus =
| "PENDING"
| "RUNNING"
| "WAITING_FOR_INPUT"
| "COMPLETED"
| "FAILED"
| "CANCELLED";
/** A run's current state. Mirrors the backend `PolicyRunView` (outputs elided). */
export interface PolicyRunView {
runId: string;
policyId: string | null;
status: PolicyRunStatus;
currentStep: number;
stepCount: number;
/** Human-readable failure message; set when status is FAILED. */
error: string | null;
errorCode: string | null;
createdAt: number;
}
/** GET /api/v1/policies/overview: KPI strip + one row per policy for the admin. */
export async function fetchPipelines(): Promise<PipelinesOverviewResponse> {
return apiClient.local.json<PipelinesOverviewResponse>(
"/api/v1/policies/overview",
);
}
/** GET /api/v1/policies/{id}: the raw policy record (steps, sources, trigger), for editing. */
export async function fetchPipeline(id: string): Promise<Policy> {
return apiClient.local.json<Policy>(
`/api/v1/policies/${encodeURIComponent(id)}`,
);
}
/** POST /api/v1/policies: create (blank id) or update (matched id) a policy. */
export async function savePipeline(policy: Policy): Promise<Policy> {
return apiClient.local.json<Policy>("/api/v1/policies", {
method: "POST",
body: policy,
});
}
/** DELETE /api/v1/policies/{id}: remove a policy. */
export async function deletePipeline(id: string): Promise<void> {
await apiClient.local.json<void>(
`/api/v1/policies/${encodeURIComponent(id)}`,
{
method: "DELETE",
},
);
}
/** GET /api/v1/policies/triggers: available triggers + their source compatibility. */
export async function fetchTriggers(): Promise<TriggerInfo[]> {
return apiClient.local.json<TriggerInfo[]>("/api/v1/policies/triggers");
}
/**
* POST /api/v1/policies/{id}/trigger: run the pipeline now against its configured
* sources, regardless of the enabled flag. Returns the ids of the runs started
* (empty when the sources yielded no work); poll {@link fetchRun} for each.
*/
export async function triggerPipeline(id: string): Promise<string[]> {
return apiClient.local.json<string[]>(
`/api/v1/policies/${encodeURIComponent(id)}/trigger`,
{ method: "POST" },
);
}
/** GET /api/v1/policies/run/{runId}: current status, error, and step cursor of a run. */
export async function fetchRun(runId: string): Promise<PolicyRunView> {
return apiClient.local.json<PolicyRunView>(
`/api/v1/policies/run/${encodeURIComponent(runId)}`,
);
}
@@ -1,32 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DeployedPipelinesTable } from "@portal/components/pipelines/DeployedPipelinesTable";
import {
DEGRADED_PIPELINE,
HEALTHY_PIPELINE,
} from "@portal/components/pipelines/storyFixtures";
import "@portal/views/Pipelines.css";
const meta: Meta<typeof DeployedPipelinesTable> = {
title: "Portal/Pipelines/DeployedPipelinesTable",
component: DeployedPipelinesTable,
parameters: { layout: "padded" },
args: { onRowClick: () => {} },
decorators: [
(S) => (
<div style={{ maxWidth: "72rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof DeployedPipelinesTable>;
/** A healthy pipeline at bound and one degraded below its golden-set bound. */
export const Default: Story = {
args: { pipelines: [HEALTHY_PIPELINE, DEGRADED_PIPELINE] },
};
export const Empty: Story = {
args: { pipelines: [] },
};
@@ -1,106 +0,0 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { StatusBadge, Table, type TableColumn } from "@shared/components";
import type { Pipeline } from "@portal/api/pipelines";
import { compact, goldenTone, pct } from "@portal/components/pipelines/format";
interface DeployedPipelinesTableProps {
pipelines: Pipeline[];
onRowClick: (p: Pipeline) => void;
}
/**
* Dense roster of the deployed fleet that puts golden-set reliability up front.
* The card list below it carries the full per-pipeline story; this table is the
* scannable "is anything below its bound?" view across the whole fleet.
*/
export function DeployedPipelinesTable({
pipelines,
onRowClick,
}: DeployedPipelinesTableProps) {
const { t } = useTranslation();
const columns = useMemo<TableColumn<Pipeline>[]>(
() => [
{
key: "name",
header: t("pipelines.table.header.name"),
render: (p) => (
<div className="portal-pipelines__roster-name">
<strong>{p.name}</strong>
<span className="portal-pipelines__roster-route">
{p.source} {p.destination}
</span>
</div>
),
},
{
key: "status",
header: t("pipelines.table.header.health"),
render: (p) => (
<StatusBadge
tone={p.status === "degraded" ? "warning" : "success"}
size="sm"
pulse={p.status === "degraded"}
>
{p.status === "degraded"
? t("pipelines.status.degraded")
: t("pipelines.status.healthy")}
</StatusBadge>
),
},
{
key: "golden",
header: t("pipelines.table.header.goldenSet"),
width: "11rem",
render: (p) => {
const tone = goldenTone(p.golden);
const rate = p.golden.total ? p.golden.passing / p.golden.total : 0;
return (
<div className="portal-pipelines__roster-golden">
<StatusBadge tone={tone} size="sm">
{p.golden.passing}/{p.golden.total}
</StatusBadge>
<span
className="portal-pipelines__roster-rate"
title={t("pipelines.table.boundTooltip", {
bound: pct(p.golden.threshold, 0),
})}
>
{pct(rate, 1)}
</span>
</div>
);
},
},
{
key: "docs",
header: t("pipelines.table.header.docs24h"),
align: "right",
render: (p) => (
<span className="portal-pipelines__roster-num">
{compact(p.metrics.docs24h)}
</span>
),
},
{
key: "version",
header: t("pipelines.table.header.version"),
align: "right",
render: (p) => (
<span className="portal-pipelines__roster-version">{p.version}</span>
),
},
],
[t],
);
return (
<Table<Pipeline>
className="portal-pipelines__roster"
columns={columns}
rows={pipelines}
rowKey={(p) => p.id}
onRowClick={onRowClick}
/>
);
}
@@ -0,0 +1,39 @@
import { useTranslation } from "react-i18next";
import { MetricCard, MetricStrip } from "@shared/components";
import type { PipelinesOverviewResponse } from "@portal/api/pipelines";
/**
* KPI labels are product copy: they describe what each metric IS, not its value.
* They stay client-side so the strip's structure is stable across loading / empty
* / ready states; only values + descriptions flow from the API. Order matches
* PolicyOverviewService.buildKpis: total, active, paused.
*/
const KPI_LABEL_KEYS = [
"pipelines.kpi.total",
"pipelines.kpi.active",
"pipelines.kpi.paused",
] as const;
interface KpiStripProps {
data: PipelinesOverviewResponse | null;
loading: boolean;
}
export function KpiStrip({ data, loading }: KpiStripProps) {
const { t } = useTranslation();
return (
<MetricStrip>
{KPI_LABEL_KEYS.map((labelKey, i) => {
const k = loading ? undefined : data?.kpis[i];
return (
<MetricCard
key={labelKey}
label={t(labelKey)}
value={k?.value ?? "—"}
description={k?.description}
/>
);
})}
</MetricStrip>
);
}
@@ -1,30 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PipelineCard } from "@portal/components/pipelines/PipelineCard";
import {
DEGRADED_PIPELINE,
HEALTHY_PIPELINE,
} from "@portal/components/pipelines/storyFixtures";
const meta: Meta<typeof PipelineCard> = {
title: "Portal/Pipelines/PipelineCard",
component: PipelineCard,
parameters: { layout: "padded" },
args: { onOpen: () => console.log("open") },
decorators: [
(S) => (
<div style={{ maxWidth: "52rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof PipelineCard>;
export const Healthy: Story = {
args: { pipeline: HEALTHY_PIPELINE },
};
export const DegradedWithDrift: Story = {
args: { pipeline: DEGRADED_PIPELINE },
};
@@ -1,132 +0,0 @@
import { useTranslation } from "react-i18next";
import { Card, StatTile, StatusBadge } from "@shared/components";
import type { Pipeline, StageSummary } from "@portal/api/pipelines";
import {
STAGE_ACCENT,
STAGE_COLOR_VAR,
} from "@portal/components/pipelines/stageAccent";
import { compact, pct } from "@portal/components/pipelines/format";
/** Compact five-dot stage indicator: a lit dot per stage that has ops. */
function StageDots({ stages }: { stages: StageSummary[] }) {
const { t } = useTranslation();
return (
<span className="portal-pipelines__stage-dots" aria-hidden>
{stages.map((s) => (
<span
key={s.key}
className="portal-pipelines__stage-dot"
style={{
background: s.ops.length
? STAGE_COLOR_VAR[STAGE_ACCENT[s.key]]
: "var(--color-border)",
}}
title={t("pipelines.card.stageTooltip", {
label: s.label,
count: s.ops.length,
})}
/>
))}
</span>
);
}
export interface PipelineCardProps {
pipeline: Pipeline;
onOpen: (p: Pipeline) => void;
}
/** Row in the deployed fleet: health, source→stages→destination rail, 24h metrics. */
export function PipelineCard({ pipeline, onOpen }: PipelineCardProps) {
const { t } = useTranslation();
const m = pipeline.metrics;
const degraded = pipeline.status === "degraded";
const errorTone =
m.errorRate >= 0.02
? "danger"
: m.errorRate >= 0.01
? "warning"
: "default";
const driftCount = pipeline.drift.length;
return (
<Card
padding="loose"
interactive
className="portal-pipelines__card"
onClick={() => onOpen(pipeline)}
>
<div className="portal-pipelines__card-head">
<div className="portal-pipelines__card-titles">
<h3 className="portal-pipelines__card-title">{pipeline.name}</h3>
<p className="portal-pipelines__card-blurb">{pipeline.blurb}</p>
</div>
<StatusBadge
tone={degraded ? "warning" : "success"}
size="sm"
pulse={degraded}
>
{degraded
? t("pipelines.status.degraded")
: t("pipelines.status.healthy")}
</StatusBadge>
</div>
<div className="portal-pipelines__card-rail">
<span className="portal-pipelines__rail-chip">{pipeline.source}</span>
<span className="portal-pipelines__rail-arrow" aria-hidden>
</span>
<StageDots stages={pipeline.stages} />
<span className="portal-pipelines__rail-arrow" aria-hidden>
</span>
<span className="portal-pipelines__rail-chip">
{pipeline.destination}
</span>
</div>
<div className="portal-pipelines__metrics">
<StatTile
label={t("pipelines.metrics.docs24h")}
value={compact(m.docs24h)}
/>
<StatTile
label={t("pipelines.metrics.throughput")}
value={`${m.throughputPerMin}/min`}
/>
<StatTile
label={t("pipelines.metrics.errorRate")}
value={pct(m.errorRate, 2)}
tone={errorTone}
/>
<StatTile
label={t("pipelines.metrics.p95Latency")}
value={`${m.p95LatencyMs} ms`}
/>
<StatTile
label={t("pipelines.metrics.uptime")}
value={pct(m.uptime, 2)}
/>
</div>
<div className="portal-pipelines__card-foot">
<span className="portal-pipelines__card-version">
{pipeline.version} · {pipeline.regions.join(", ")}
</span>
<span className="portal-pipelines__card-golden">
{t("pipelines.card.golden", {
passing: pipeline.golden.passing,
total: pipeline.golden.total,
})}
{driftCount > 0 && (
<span className="portal-pipelines__card-drift">
{" · "}
{t("pipelines.card.drift", { count: driftCount })}
</span>
)}
</span>
</div>
</Card>
);
}
@@ -1,25 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PipelineComposer } from "@portal/components/pipelines/PipelineComposer";
const meta: Meta<typeof PipelineComposer> = {
title: "Portal/Pipelines/PipelineComposer",
component: PipelineComposer,
parameters: { layout: "fullscreen" },
args: { open: true, onClose: () => console.log("close") },
decorators: [
(S) => (
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof PipelineComposer>;
/** Opens on the source step; step through Operations and Routing in the footer. */
export const Open: Story = {};
export const Closed: Story = {
args: { open: false },
};
@@ -1,350 +1,474 @@
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Chip, Modal } from "@shared/components";
import {
DESTINATION_OPTIONS,
PIPELINE_OPS,
PIPELINE_AGENTS,
SOURCE_OPTIONS,
type OpKind,
type PipelineOp,
} from "@shared/data/ops";
Banner,
Button,
Checkbox,
Chip,
FormField,
Input,
Modal,
RadioGroup,
Select,
} from "@shared/components";
import { errorMessage } from "@portal/api/http";
import {
OP_KIND_ACCENT,
STAGE_COLOR_VAR,
} from "@portal/components/pipelines/stageAccent";
fetchTriggers,
savePipeline,
type OutputSpec,
type PipelineStep,
type Policy,
type TriggerConfig,
type TriggerInfo,
} from "@portal/api/pipelines";
import { fetchSources, type SourceView } from "@portal/api/sources";
import { useAsync } from "@portal/hooks/useAsync";
import {
PIPELINE_OPERATIONS,
humanizeOperation,
} from "@portal/components/pipelines/pipelineOperations";
import "@portal/views/Pipelines.css";
const COMPOSER_STEPS = ["source", "operations", "routing"] as const;
type OutputMode = "inline" | "folder";
type ScheduleUnit = "MINUTES" | "HOURS" | "DAYS";
/** Translation key suffixes for each op-kind group heading in the picker. */
const OP_KIND_LABEL_KEY: Record<OpKind, string> = {
ingest: "ingest",
validate: "validate",
modify: "modify",
secure: "secure",
store: "store",
alert: "alert",
};
const SCHEDULE_UNITS: ScheduleUnit[] = ["MINUTES", "HOURS", "DAYS"];
/** Empty trigger type = manual-only (no automatic trigger). */
const MANUAL = "";
/** Selectable ops in the picker — excludes pipeline-only structural ops. */
const PICKER_OPS: Record<OpKind, PipelineOp[]> = (() => {
const out = {} as Record<OpKind, PipelineOp[]>;
for (const kind of Object.keys(PIPELINE_OPS) as OpKind[]) {
out[kind] = PIPELINE_OPS[kind].filter((op) => !op.pipelineOnly);
}
return out;
})();
function lookupPickerOp(id: string): PipelineOp | null {
for (const kind of Object.keys(PICKER_OPS) as OpKind[]) {
const found = PICKER_OPS[kind].find((op) => op.id === id);
if (found) return found;
}
return null;
}
export interface PipelineComposerProps {
interface PipelineComposerProps {
open: boolean;
onClose: () => void;
/** Called after a pipeline is created or updated so the page can refetch. */
onSaved: () => void;
/** When set, the composer edits this existing policy instead of creating one. */
pipeline?: Policy;
}
/** Three-step wizard: pick a source, compose the op chain, route the output. */
export function PipelineComposer({ open, onClose }: PipelineComposerProps) {
/**
* A policy's trigger parsed into the composer's fields: which trigger type (empty
* = manual), and the schedule interval when it's a schedule trigger.
*/
function parseTrigger(trigger: TriggerConfig | null): {
triggerType: string;
count: string;
unit: ScheduleUnit;
} {
if (!trigger) return { triggerType: MANUAL, count: "1", unit: "HOURS" };
if (trigger.type === "schedule") {
const schedule = trigger.options?.schedule as
| { type?: string; count?: number; unit?: ScheduleUnit }
| undefined;
if (schedule?.type === "every") {
return {
triggerType: "schedule",
count: String(schedule.count ?? 1),
unit: schedule.unit ?? "HOURS",
};
}
// Non-interval schedules (daily/weekly/monthly) aren't editable here; show
// the schedule choice with defaults the user can re-set.
return { triggerType: "schedule", count: "1", unit: "HOURS" };
}
return { triggerType: trigger.type, count: "1", unit: "HOURS" };
}
/** Output sink fields parsed from a policy's output for editing. */
function parseOutput(output: OutputSpec | undefined): {
mode: OutputMode;
directory: string;
} {
if (output?.type === "folder") {
return {
mode: "folder",
directory: String(output.options?.directory ?? ""),
};
}
return { mode: "inline", directory: "" };
}
/**
* Compose a pipeline (a backend policy): name it, pick the sources it pulls from
* and how it's triggered, chain operations, and choose where output goes. On submit
* a blank id creates and a set id updates, matching the backend's POST contract.
* Per-operation parameter editing is out of scope here; operations are chained with
* their defaults.
*/
export function PipelineComposer({
open,
onClose,
onSaved,
pipeline,
}: PipelineComposerProps) {
const { t } = useTranslation();
const [step, setStep] = useState(0);
const [source, setSource] = useState<string>("upload");
const [selectedOps, setSelectedOps] = useState<string[]>([
"extract",
"validate",
"redact",
]);
const [destination, setDestination] = useState<string>("vault");
const [notifyEmail, setNotifyEmail] = useState(true);
const [notifyWebhook, setNotifyWebhook] = useState(false);
const [reviewQueue, setReviewQueue] = useState(true);
const isEdit = pipeline !== undefined;
function reset() {
setStep(0);
setSource("upload");
setSelectedOps(["extract", "validate", "redact"]);
setDestination("vault");
setNotifyEmail(true);
setNotifyWebhook(false);
setReviewQueue(true);
}
const sourcesState = useAsync<SourceView[]>(
async () => (open ? (await fetchSources()).sources : []),
[open],
);
const availableSources = sourcesState.data ?? [];
function close() {
onClose();
// Defer reset so it doesn't flash mid-close-animation.
setTimeout(reset, 0);
}
// The triggers the backend supports, with their source-type compatibility, so
// the UI offers them (and pairs them with sources) without hard-coding the set.
const triggersState = useAsync<TriggerInfo[]>(
async () => (open ? await fetchTriggers() : []),
[open],
);
const triggers = useMemo(
() => triggersState.data ?? [],
[triggersState.data],
);
function deploy() {
// TODO(backend): POST /v1/pipelines { source, ops: selectedOps, destination, alerts }
close();
}
const [name, setName] = useState("");
const [sourceIds, setSourceIds] = useState<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);
function toggleOp(id: string) {
setSelectedOps((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
// Re-seed the form whenever the composer opens (or its target changes) so editing
// prefills the current config and a reopened create starts clean.
useEffect(() => {
if (!open) return;
const trigger = parseTrigger(pipeline?.trigger ?? null);
const output = parseOutput(pipeline?.output);
setName(pipeline?.name ?? "");
setSourceIds(pipeline?.sourceIds ?? []);
setSteps(pipeline?.steps ?? []);
setTriggerType(trigger.triggerType);
setScheduleCount(trigger.count);
setScheduleUnit(trigger.unit);
setOutputMode(output.mode);
setOutputDirectory(output.directory);
setSubmitting(false);
setError(null);
}, [open, pipeline]);
// Types of the currently-selected sources, for trigger compatibility.
const selectedSourceTypes = useMemo(
() =>
new Set(
availableSources
.filter((s) => sourceIds.includes(s.id))
.map((s) => s.type),
),
[availableSources, sourceIds],
);
const triggerAvailable = useMemo(
() => (trigger: TriggerInfo) =>
!trigger.requiresSource ||
trigger.supportedSourceTypes.some((type) =>
selectedSourceTypes.has(type),
),
[selectedSourceTypes],
);
// A source-requiring trigger stops being valid the moment its compatible source
// is deselected; fall back to manual so we never submit an impossible trigger.
useEffect(() => {
if (triggerType === MANUAL) return;
const selected = triggers.find((trigger) => trigger.type === triggerType);
if (selected && !triggerAvailable(selected)) setTriggerType(MANUAL);
}, [triggerType, triggers, triggerAvailable]);
function toggleSource(id: string, checked: boolean) {
setSourceIds((ids) =>
checked ? [...ids, id] : ids.filter((existing) => existing !== id),
);
}
function applyAgent(ops: string[]) {
// A bundle lists its full op set, but structural rails (retention,
// residency, access policy) aren't user-chainable picker ops — add only
// ops that exist in the picker so every chain chip resolves to a label.
const pickable = ops.filter((id) => lookupPickerOp(id) !== null);
setSelectedOps((prev) => Array.from(new Set([...prev, ...pickable])));
function addStep(operation: string, parameters: Record<string, unknown>) {
setSteps((current) => [
...current,
{ operation, parameters: { ...parameters } },
]);
}
const isLast = step === COMPOSER_STEPS.length - 1;
const canAdvance = step === 1 ? selectedOps.length > 0 : true;
function removeStep(index: number) {
setSteps((current) => current.filter((_, i) => i !== index));
}
function moveStep(index: number, delta: number) {
setSteps((current) => {
const next = [...current];
const target = index + delta;
if (target < 0 || target >= next.length) return current;
[next[index], next[target]] = [next[target], next[index]];
return next;
});
}
const scheduleCountValid =
triggerType !== "schedule" || Number(scheduleCount) > 0;
const outputValid = outputMode !== "folder" || outputDirectory.trim() !== "";
const canSave =
name.trim() !== "" && scheduleCountValid && outputValid && !submitting;
const triggerOptions = [
{ value: MANUAL, label: t("pipelines.composer.triggerManual") },
...triggers.map((trigger) => ({
value: trigger.type,
label: t(`pipelines.trigger.${trigger.type}`, {
defaultValue: trigger.type,
}),
disabled: !triggerAvailable(trigger),
})),
];
function buildTrigger(): TriggerConfig | null {
if (triggerType === MANUAL) return null;
if (triggerType === "schedule") {
return {
type: "schedule",
options: {
schedule: {
type: "every",
count: Number(scheduleCount),
unit: scheduleUnit,
},
},
};
}
return { type: triggerType, options: {} };
}
async function submit() {
if (!canSave) return;
setSubmitting(true);
setError(null);
const output: OutputSpec =
outputMode === "folder"
? { type: "folder", options: { directory: outputDirectory.trim() } }
: { type: "inline", options: {} };
const policy: Policy = {
id: pipeline?.id,
name: name.trim(),
enabled: pipeline?.enabled ?? true,
trigger: buildTrigger(),
sourceIds,
steps,
output,
};
try {
await savePipeline(policy);
onSaved();
onClose();
} catch (e) {
setError(errorMessage(e));
} finally {
setSubmitting(false);
}
}
return (
<Modal
open={open}
onClose={close}
width="xl"
title={t("pipelines.composer.title")}
onClose={onClose}
width="lg"
title={
isEdit
? t("pipelines.composer.editTitle")
: t("pipelines.composer.title")
}
subtitle={t("pipelines.composer.subtitle")}
footer={
<>
<div className="portal-pipelines__composer-steps" aria-hidden>
{COMPOSER_STEPS.map((stepId, i) => (
<span
key={stepId}
className={
"portal-pipelines__composer-step" +
(i === step ? " is-active" : i < step ? " is-done" : "")
}
<div className="portal-pipelines__composer-footer">
<Button
variant="ghost"
size="sm"
disabled={submitting}
onClick={onClose}
>
{i + 1}. {t(`pipelines.composer.steps.${stepId}`)}
</span>
))}
</div>
<Button variant="ghost" onClick={close}>
{t("pipelines.composer.cancel")}
</Button>
{step > 0 && (
<Button variant="outline" onClick={() => setStep((s) => s - 1)}>
{t("pipelines.composer.back")}
</Button>
)}
{isLast ? (
<Button
variant="gradient"
onClick={deploy}
trailingIcon={<span aria-hidden></span>}
size="sm"
onClick={submit}
loading={submitting}
disabled={!canSave}
>
{t("pipelines.composer.deploy")}
{isEdit
? t("pipelines.composer.save")
: t("pipelines.composer.create")}
</Button>
) : (
<Button
variant="gradient"
onClick={() => setStep((s) => s + 1)}
disabled={!canAdvance}
trailingIcon={<span aria-hidden></span>}
>
{t("pipelines.composer.continue")}
</Button>
)}
</>
</div>
}
>
<div className="portal-pipelines__composer">
{step === 0 && (
<div className="portal-pipelines__composer-body">
<div className="portal-pipelines__composer-grid">
<button
type="button"
className={
"portal-pipelines__option" +
(source === "any" ? " is-selected" : "")
}
onClick={() => setSource("any")}
>
<span className="portal-pipelines__option-label">
{t("pipelines.composer.anySource.label")}
</span>
<span className="portal-pipelines__option-desc">
{t("pipelines.composer.anySource.desc")}
</span>
</button>
{SOURCE_OPTIONS.map((opt) => (
<button
key={opt.id}
type="button"
className={
"portal-pipelines__option" +
(source === opt.id ? " is-selected" : "")
}
onClick={() => setSource(opt.id)}
>
<span className="portal-pipelines__option-label">
{opt.label}
</span>
<span className="portal-pipelines__option-desc">
{opt.desc}
</span>
</button>
))}
</div>
</div>
)}
<FormField label={t("pipelines.composer.name")} required>
<Input
value={name}
placeholder={t("pipelines.composer.namePlaceholder")}
onChange={(e) => setName(e.target.value)}
/>
</FormField>
{step === 1 && (
<div className="portal-pipelines__composer-body">
<div className="portal-pipelines__chain">
<span className="portal-pipelines__chain-label">
{t("pipelines.composer.operationChain", {
count: selectedOps.length,
})}
</span>
<div className="portal-pipelines__chain-chips">
{selectedOps.length === 0 ? (
<span className="portal-pipelines__chain-empty">
{t("pipelines.composer.chainEmpty")}
{/* Sources */}
<div className="portal-pipelines__composer-section">
<span className="portal-pipelines__detail-heading">
{t("pipelines.composer.sources")}
</span>
{sourcesState.loading ? (
<p className="portal-pipelines__muted">
{t("pipelines.composer.sourcesLoading")}
</p>
) : availableSources.length === 0 ? (
<p className="portal-pipelines__muted">
{t("pipelines.composer.noSources")}
</p>
) : (
selectedOps.map((id) => {
const op = lookupPickerOp(id);
const accent = op ? OP_KIND_ACCENT[op.kind] : "purple";
return (
<Chip
key={id}
tone={accent}
size="sm"
onRemove={() => toggleOp(id)}
>
{op?.label ?? id}
</Chip>
);
})
)}
</div>
</div>
<div className="portal-pipelines__agents">
<span className="portal-pipelines__agents-label">
{t("pipelines.composer.quickAddBundles")}
</span>
<div className="portal-pipelines__agents-row">
{PIPELINE_AGENTS.map((agent) => (
<Chip
key={agent.id}
tone="neutral"
size="sm"
onClick={() => applyAgent(agent.ops)}
>
+ {agent.label}
</Chip>
))}
</div>
</div>
<div className="portal-pipelines__library">
{(Object.keys(PICKER_OPS) as OpKind[]).map((kind) => (
<div key={kind} className="portal-pipelines__library-group">
<div className="portal-pipelines__library-head">
<span
className="portal-pipelines__library-pip"
style={{
background: STAGE_COLOR_VAR[OP_KIND_ACCENT[kind]],
}}
aria-hidden
<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}
/>
{t(`pipelines.composer.opKind.${OP_KIND_LABEL_KEY[kind]}`)}
</div>
<div className="portal-pipelines__library-chips">
{PICKER_OPS[kind].map((op) => {
const on = selectedOps.includes(op.id);
return (
<Chip
key={op.id}
tone={on ? OP_KIND_ACCENT[kind] : "neutral"}
size="sm"
onClick={() => toggleOp(op.id)}
>
{on ? "✓ " : ""}
{op.label}
</Chip>
);
})}
</div>
</div>
))}
</div>
</div>
)}
{step === 2 && (
<div className="portal-pipelines__composer-body">
<span className="portal-pipelines__chain-label">
{t("pipelines.composer.destination")}
<span className="portal-pipelines__detail-heading">
{t("pipelines.composer.trigger")}
</span>
<div className="portal-pipelines__composer-grid">
{DESTINATION_OPTIONS.map((opt) => (
<button
key={opt.id}
type="button"
className={
"portal-pipelines__option" +
(destination === opt.id ? " is-selected" : "")
<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("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={(e) =>
setScheduleUnit(e.target.value as ScheduleUnit)
}
onClick={() => setDestination(opt.id)}
>
<span className="portal-pipelines__option-label">
{opt.label}
</span>
<span className="portal-pipelines__option-desc">
{opt.desc}
</span>
</button>
))}
</div>
<span className="portal-pipelines__chain-label">
{t("pipelines.composer.alerts")}
</span>
<div className="portal-pipelines__alerts">
<label className="portal-pipelines__alert">
<input
type="checkbox"
checked={notifyEmail}
onChange={(e) => setNotifyEmail(e.target.checked)}
options={SCHEDULE_UNITS.map((unit) => ({
value: unit,
label: t(`pipelines.composer.unit.${unit.toLowerCase()}`),
}))}
/>
<span>
<strong>{t("pipelines.composer.alert.email.title")}</strong>
<span>{t("pipelines.composer.alert.email.desc")}</span>
</span>
</label>
<label className="portal-pipelines__alert">
<input
type="checkbox"
checked={notifyWebhook}
onChange={(e) => setNotifyWebhook(e.target.checked)}
/>
<span>
<strong>{t("pipelines.composer.alert.webhook.title")}</strong>
<span>{t("pipelines.composer.alert.webhook.desc")}</span>
</span>
</label>
<label className="portal-pipelines__alert">
<input
type="checkbox"
checked={reviewQueue}
onChange={(e) => setReviewQueue(e.target.checked)}
/>
<span>
<strong>{t("pipelines.composer.alert.review.title")}</strong>
<span>{t("pipelines.composer.alert.review.desc")}</span>
</span>
</label>
</div>
</div>
)}
</div>
{/* Operations */}
<div className="portal-pipelines__composer-section">
<span className="portal-pipelines__detail-heading">
{t("pipelines.composer.operations", { count: steps.length })}
</span>
{steps.length === 0 ? (
<p className="portal-pipelines__muted">
{t("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">
<button
type="button"
aria-label={t("pipelines.composer.moveUp")}
disabled={i === 0}
onClick={() => moveStep(i, -1)}
>
</button>
<button
type="button"
aria-label={t("pipelines.composer.moveDown")}
disabled={i === steps.length - 1}
onClick={() => moveStep(i, 1)}
>
</button>
<button
type="button"
aria-label={t("pipelines.composer.removeStep")}
onClick={() => removeStep(i)}
>
×
</button>
</div>
</li>
))}
</ol>
)}
<div className="portal-pipelines__op-palette">
{PIPELINE_OPERATIONS.map((op) => (
<Chip
key={op.operation}
tone="blue"
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("pipelines.composer.output")}
</span>
<RadioGroup<OutputMode>
name="pipeline-output"
value={outputMode}
onChange={setOutputMode}
direction="horizontal"
options={[
{ value: "inline", label: t("pipelines.output.inline") },
{ value: "folder", label: t("pipelines.output.folder") },
]}
/>
{outputMode === "folder" && (
<FormField
label={t("pipelines.composer.directory")}
helperText={t("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,31 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PipelineDetail } from "@portal/components/pipelines/PipelineDetail";
import {
DEGRADED_PIPELINE,
HEALTHY_PIPELINE,
} from "@portal/components/pipelines/storyFixtures";
const meta: Meta<typeof PipelineDetail> = {
title: "Portal/Pipelines/PipelineDetail",
component: PipelineDetail,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "32rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof PipelineDetail>;
/** Clean golden set, no drift. */
export const Healthy: Story = {
args: { pipeline: HEALTHY_PIPELINE },
};
/** Failing golden cases plus warning- and info-severity schema drift. */
export const DegradedWithDrift: Story = {
args: { pipeline: DEGRADED_PIPELINE },
};
@@ -1,183 +0,0 @@
import { useTranslation } from "react-i18next";
import {
Chip,
EmptyState,
ProgressBar,
StatTile,
StatusBadge,
} from "@shared/components";
import type { Pipeline, SchemaDrift } from "@portal/api/pipelines";
import {
STAGE_ACCENT,
STAGE_COLOR_VAR,
} from "@portal/components/pipelines/stageAccent";
import { compact, pct } from "@portal/components/pipelines/format";
function DriftRow({ drift }: { drift: SchemaDrift }) {
const { t } = useTranslation();
const confDelta =
(drift.confidenceDelta > 0 ? "+" : "") + drift.confidenceDelta.toFixed(2);
return (
<li className="portal-pipelines__drift">
<span
className="portal-pipelines__drift-dot"
style={{
background:
drift.severity === "warning"
? "var(--color-amber)"
: "var(--color-blue)",
}}
aria-hidden
/>
<div className="portal-pipelines__drift-text">
<code className="portal-pipelines__drift-field">{drift.field}</code>
<span className="portal-pipelines__drift-note">{drift.note}</span>
</div>
<div className="portal-pipelines__drift-meta">
<span>
{t("pipelines.detail.drift.confidence", { delta: confDelta })}
</span>
<span>
{t("pipelines.detail.drift.docs", { count: drift.affectedDocs })}
</span>
</div>
</li>
);
}
export interface PipelineDetailProps {
pipeline: Pipeline;
}
/** Drawer body: 24h metrics, the five stages, golden-set health, and schema drift. */
export function PipelineDetail({ pipeline }: PipelineDetailProps) {
const { t } = useTranslation();
const m = pipeline.metrics;
const goldenRatio = pipeline.golden.total
? pipeline.golden.passing / pipeline.golden.total
: 0;
const goldenClean = pipeline.golden.passing === pipeline.golden.total;
return (
<div className="portal-pipelines__detail">
<section className="portal-pipelines__detail-metrics">
<StatTile
label={t("pipelines.metrics.docs24h")}
value={compact(m.docs24h)}
/>
<StatTile
label={t("pipelines.metrics.throughput")}
value={`${m.throughputPerMin}/min`}
/>
<StatTile
label={t("pipelines.metrics.errorRate")}
value={pct(m.errorRate, 2)}
/>
<StatTile
label={t("pipelines.metrics.p95Latency")}
value={`${m.p95LatencyMs} ms`}
/>
<StatTile
label={t("pipelines.metrics.uptime")}
value={pct(m.uptime, 2)}
/>
</section>
<section className="portal-pipelines__detail-section">
<h3 className="portal-pipelines__detail-h">
{t("pipelines.detail.stages.heading")}
</h3>
<p className="portal-pipelines__detail-sub">
{t("pipelines.detail.stages.description", {
source: pipeline.source,
destination: pipeline.destination,
})}
</p>
<div className="portal-pipelines__stages">
{pipeline.stages.map((stage) => {
const accent = STAGE_ACCENT[stage.key];
return (
<div key={stage.key} className="portal-pipelines__stage">
<div className="portal-pipelines__stage-head">
<span
className="portal-pipelines__stage-pip"
style={{ background: STAGE_COLOR_VAR[accent] }}
aria-hidden
/>
<span className="portal-pipelines__stage-name">
{stage.label}
</span>
</div>
<div className="portal-pipelines__stage-chips">
{stage.ops.length === 0 ? (
<span className="portal-pipelines__stage-empty">
{t("pipelines.detail.stages.noOps")}
</span>
) : (
stage.ops.map((op) => (
<Chip key={op} tone={accent} size="sm">
{op}
</Chip>
))
)}
</div>
</div>
);
})}
</div>
</section>
<section className="portal-pipelines__detail-section">
<h3 className="portal-pipelines__detail-h">
{t("pipelines.detail.golden.heading")}
</h3>
<div className="portal-pipelines__golden">
<div className="portal-pipelines__golden-head">
<StatusBadge tone={goldenClean ? "success" : "warning"} size="sm">
{t("pipelines.detail.golden.passing", {
passing: pipeline.golden.passing,
total: pipeline.golden.total,
})}
</StatusBadge>
<span className="portal-pipelines__golden-when">
{t("pipelines.detail.golden.lastRun", {
lastRun: pipeline.golden.lastRun,
})}
</span>
</div>
<ProgressBar
value={goldenRatio}
color={
goldenClean
? "var(--color-green)"
: "linear-gradient(90deg, var(--color-amber), color-mix(in srgb, var(--color-amber) 70%, white))"
}
label={t("pipelines.detail.golden.barLabel", {
passing: pipeline.golden.passing,
total: pipeline.golden.total,
})}
/>
</div>
</section>
<section className="portal-pipelines__detail-section">
<h3 className="portal-pipelines__detail-h">
{t("pipelines.detail.drift.heading")}
</h3>
{pipeline.drift.length === 0 ? (
<EmptyState
size="compact"
title={t("pipelines.detail.drift.empty.title")}
description={t("pipelines.detail.drift.empty.description")}
/>
) : (
<ul className="portal-pipelines__drift-list">
{pipeline.drift.map((d) => (
<DriftRow key={d.field} drift={d} />
))}
</ul>
)}
</section>
</div>
);
}
@@ -0,0 +1,213 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Banner, Button, Chip } from "@shared/components";
import { errorMessage } from "@portal/api/http";
import {
fetchRun,
triggerPipeline,
type PipelineView,
type PolicyRunView,
} from "@portal/api/pipelines";
import { humanizeOperation } from "@portal/components/pipelines/pipelineOperations";
import "@portal/views/Pipelines.css";
const TERMINAL_STATUSES = new Set(["COMPLETED", "FAILED", "CANCELLED"]);
const POLL_INTERVAL_MS = 1500;
const POLL_ATTEMPTS = 60;
type RunResult = { tone: "success" | "danger" | "info"; text: string };
interface PipelineDetailCardProps {
pipeline: PipelineView;
onClose: () => void;
onEdit: (pipeline: PipelineView) => void;
onTogglePause: (pipeline: PipelineView) => void;
onDelete: (pipeline: PipelineView) => void;
/** Disables the actions while a page-level mutation is in flight. */
busy?: boolean;
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/** Expanded detail for the selected pipeline row, with run/edit/pause/delete actions. */
export function PipelineDetailCard({
pipeline,
onClose,
onEdit,
onTogglePause,
onDelete,
busy = false,
}: PipelineDetailCardProps) {
const { t } = useTranslation();
const paused = pipeline.status === "paused";
const [running, setRunning] = useState(false);
const [runResult, setRunResult] = useState<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("pipelines.run.empty") });
return;
}
const finals = await Promise.all(runIds.map((id) => awaitRun(id)));
if (!mounted.current) return;
const failed = finals.find((r) => r?.status === "FAILED");
if (failed) {
setRunResult({
tone: "danger",
text: t("pipelines.run.failed", { error: failed.error ?? "" }),
});
} else if (finals.every((r) => r?.status === "COMPLETED")) {
setRunResult({
tone: "success",
text: t("pipelines.run.completed", { count: finals.length }),
});
} else {
// Still running when we stopped polling, or cancelled.
setRunResult({ tone: "info", text: t("pipelines.run.running") });
}
} catch (e) {
if (mounted.current)
setRunResult({ tone: "danger", text: errorMessage(e) });
} finally {
if (mounted.current) setRunning(false);
}
}
return (
<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("pipelines.detail.subtitle", {
trigger: t(`pipelines.trigger.${pipeline.trigger}`, {
defaultValue: pipeline.trigger,
}),
status: t(`pipelines.status.${pipeline.status}`),
})}
</span>
</div>
<button
type="button"
className="portal-pipelines__expanded-close"
onClick={onClose}
aria-label={t("pipelines.detail.closeAriaLabel")}
>
×
</button>
</header>
<div className="portal-pipelines__detail">
<div className="portal-pipelines__detail-section">
<span className="portal-pipelines__detail-heading">
{t("pipelines.detail.steps")}
</span>
{pipeline.steps.length === 0 ? (
<p className="portal-pipelines__muted">
{t("pipelines.detail.noSteps")}
</p>
) : (
<div className="portal-pipelines__chips">
{pipeline.steps.map((step, i) => (
<Chip key={`${step}-${i}`} tone="blue" size="sm">
{`${i + 1}. ${humanizeOperation(step)}`}
</Chip>
))}
</div>
)}
</div>
<div className="portal-pipelines__detail-section">
<span className="portal-pipelines__detail-heading">
{t("pipelines.detail.sources")}
</span>
{pipeline.sources.length === 0 ? (
<p className="portal-pipelines__muted">
{t("pipelines.detail.noSources")}
</p>
) : (
<div className="portal-pipelines__chips">
{pipeline.sources.map((source) => (
<Chip key={source.id} tone="neutral" size="sm">
{source.name}
</Chip>
))}
</div>
)}
</div>
<div className="portal-pipelines__detail-section">
<span className="portal-pipelines__detail-heading">
{t("pipelines.detail.output")}
</span>
<Chip tone="purple" size="sm">
{t(`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("pipelines.detail.run")}
</Button>
<Button
variant="outline"
disabled={busy || running}
onClick={() => onEdit(pipeline)}
>
{t("pipelines.detail.edit")}
</Button>
<Button
variant="outline"
disabled={busy || running}
onClick={() => onTogglePause(pipeline)}
>
{paused ? t("pipelines.detail.resume") : t("pipelines.detail.pause")}
</Button>
<Button
accent="red"
variant="outline"
disabled={busy || running}
onClick={() => onDelete(pipeline)}
>
{t("pipelines.detail.delete")}
</Button>
</div>
</section>
);
}
@@ -1,19 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PipelineListSkeleton } from "@portal/components/pipelines/PipelineListSkeleton";
const meta: Meta<typeof PipelineListSkeleton> = {
title: "Portal/Pipelines/PipelineListSkeleton",
component: PipelineListSkeleton,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "52rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof PipelineListSkeleton>;
export const Default: Story = {};
@@ -1,19 +0,0 @@
import { Card, Skeleton } from "@shared/components";
/** Placeholder fleet while the deployed pipelines load. */
export function PipelineListSkeleton() {
return (
<div className="portal-pipelines__list" aria-hidden>
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i} padding="loose" className="portal-pipelines__card">
<div className="portal-pipelines__card-head">
<Skeleton width="11rem" height="1.1rem" />
<Skeleton width="5rem" height="1.1rem" />
</div>
<Skeleton width="80%" height="0.75rem" />
<Skeleton height="3rem" />
</Card>
))}
</div>
);
}
@@ -0,0 +1,122 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
Chip,
StatusBadge,
type StatusTone,
Table,
type TableColumn,
} from "@shared/components";
import type { PipelineStatus, PipelineView } from "@portal/api/pipelines";
const STATUS_TONE: Record<PipelineStatus, StatusTone> = {
active: "success",
paused: "neutral",
};
interface PipelinesTableProps {
pipelines: PipelineView[];
/** Id of the row whose detail panel is open, drives the caret state. */
expandedId: string | null;
onRowClick: (pipeline: PipelineView) => void;
}
export function PipelinesTable({
pipelines,
expandedId,
onRowClick,
}: PipelinesTableProps) {
const { t } = useTranslation();
const columns = useMemo<TableColumn<PipelineView>[]>(
() => [
{
key: "name",
header: t("pipelines.table.name"),
render: (p) => (
<div className="portal-pipelines__name-cell">
<span className="portal-pipelines__pipe-dot" aria-hidden>
</span>
<div className="portal-pipelines__name-text">
<strong>{p.name}</strong>
<Chip tone="neutral" size="sm">
{t(`pipelines.trigger.${p.trigger}`, {
defaultValue: p.trigger,
})}
</Chip>
</div>
</div>
),
},
{
key: "status",
header: t("pipelines.table.status"),
render: (p) => (
<StatusBadge
tone={STATUS_TONE[p.status]}
size="sm"
pulse={p.status === "active"}
>
{t(`pipelines.status.${p.status}`)}
</StatusBadge>
),
},
{
key: "steps",
header: t("pipelines.table.steps"),
align: "right",
render: (p) => (
<span
className={
p.steps.length === 0 ? "portal-pipelines__muted" : undefined
}
>
{p.steps.length}
</span>
),
},
{
key: "sources",
header: t("pipelines.table.sources"),
align: "right",
render: (p) => (
<span
className={
p.sources.length === 0 ? "portal-pipelines__muted" : undefined
}
>
{p.sources.length}
</span>
),
},
{
key: "expand",
header: "",
align: "right",
width: "2.5rem",
render: (p) => (
<span
className={
"portal-pipelines__caret" +
(expandedId === p.id ? " is-open" : "")
}
aria-hidden
>
</span>
),
},
],
[expandedId, t],
);
return (
<Table<PipelineView>
className="portal-pipelines__table"
columns={columns}
rows={pipelines}
rowKey={(p) => p.id}
onRowClick={onRowClick}
/>
);
}
@@ -1,26 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PromotedPipelines } from "@portal/components/pipelines/PromotedPipelines";
import { PROMOTED_PIPELINES } from "@portal/components/pipelines/storyFixtures";
import "@portal/views/Pipelines.css";
const meta: Meta<typeof PromotedPipelines> = {
title: "Portal/Pipelines/PromotedPipelines",
component: PromotedPipelines,
parameters: { layout: "padded" },
args: { promoted: PROMOTED_PIPELINES },
decorators: [
(S) => (
<div style={{ maxWidth: "72rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof PromotedPipelines>;
export const Default: Story = {};
export const Empty: Story = {
args: { promoted: [] },
};
@@ -1,139 +0,0 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Button,
StatusBadge,
type StatusTone,
Table,
type TableColumn,
} from "@shared/components";
import {
promoteToPolicy,
type PromotedPipeline,
type PromotedStatus,
} from "@portal/api/pipelines";
const STATUS_TONE: Record<PromotedStatus, StatusTone> = {
deployed: "success",
staged: "info",
review: "warning",
};
/** Translation key suffixes for each promoted-pipeline status badge. */
const STATUS_LABEL_KEY: Record<PromotedStatus, string> = {
deployed: "deployed",
staged: "staged",
review: "review",
};
/** Per-row promote-to-policy lifecycle, kept local until a backend exists. */
type PromoteState = "idle" | "pending" | "done";
interface PromotedPipelinesProps {
promoted: PromotedPipeline[];
}
/**
* Flows that started as Editor watch-folder automations and were promoted into
* the portal. Each keeps a pointer back to the watch folder it grew from, and
* offers a one-click path to lift its rules into a fleet-wide org policy.
*/
export function PromotedPipelines({ promoted }: PromotedPipelinesProps) {
const { t } = useTranslation();
// Promote submits have no backend yet, so reflect acceptance per row locally.
const [promoteState, setPromoteState] = useState<
Record<string, PromoteState>
>({});
const onPromote = async (p: PromotedPipeline) => {
setPromoteState((s) => ({ ...s, [p.id]: "pending" }));
try {
// TODO(backend): POST /v1/pipelines/{id}/promote-to-policy — stubbed,
// resolves against the mock handler; treat success as accepted.
await promoteToPolicy(p.id);
setPromoteState((s) => ({ ...s, [p.id]: "done" }));
} catch {
setPromoteState((s) => ({ ...s, [p.id]: "idle" }));
}
};
const columns = useMemo<TableColumn<PromotedPipeline>[]>(
() => [
{
key: "name",
header: t("pipelines.table.header.name"),
render: (p) => (
<div className="portal-pipelines__promoted-name">
<strong>{p.name}</strong>
<span className="portal-pipelines__promoted-when">
{p.promotedAt}
</span>
</div>
),
},
{
key: "docType",
header: t("pipelines.promoted.table.sourceDocType"),
render: (p) => (
<span className="portal-pipelines__promoted-muted">
{p.sourceDocType}
</span>
),
},
{
key: "watchFolder",
header: t("pipelines.promoted.table.watchFolder"),
render: (p) => (
<code className="portal-pipelines__promoted-folder">
{p.watchFolder}
</code>
),
},
{
key: "status",
header: t("pipelines.promoted.table.status"),
render: (p) => (
<StatusBadge tone={STATUS_TONE[p.status]} size="sm">
{t(`pipelines.promoted.status.${STATUS_LABEL_KEY[p.status]}`)}
</StatusBadge>
),
},
{
key: "promote",
header: "",
align: "right",
width: "11rem",
render: (p) => {
const state = promoteState[p.id] ?? "idle";
if (state === "done") {
return (
<StatusBadge tone="success" size="sm">
{t("pipelines.promoted.policyCreated")}
</StatusBadge>
);
}
return (
<Button
variant="outline"
size="sm"
loading={state === "pending"}
onClick={() => onPromote(p)}
>
{t("pipelines.promoted.promoteToPolicy")}
</Button>
);
},
},
],
[promoteState, t],
);
return (
<Table<PromotedPipeline>
className="portal-pipelines__promoted"
columns={columns}
rows={promoted}
rowKey={(p) => p.id}
/>
);
}
@@ -1,26 +0,0 @@
import type { GoldenSet } from "@portal/api/pipelines";
import type { StatusTone } from "@shared/components";
/** Fraction → percentage string (0.004 → "0.40%"). */
export const pct = (n: number, digits = 1) => `${(n * 100).toFixed(digits)}%`;
/** Compact count for dense metric tiles (28941 → "28.9K"). */
export const compact = (n: number) =>
new Intl.NumberFormat(undefined, {
notation: "compact",
maximumFractionDigits: 1,
}).format(n);
/**
* Golden-set reliability tone, judged against the pipeline's own pass-rate
* bound. At/above bound is green; a small slip under is amber; a clear miss is
* danger — so a row's reliability reads from colour alone.
*/
export function goldenTone(golden: GoldenSet): StatusTone {
if (golden.total === 0) return "neutral";
const rate = golden.passing / golden.total;
if (rate >= golden.threshold) return "success";
// Within five points of the bound is a warning; further off is a hard miss.
if (rate >= golden.threshold - 0.05) return "warning";
return "danger";
}
@@ -0,0 +1,50 @@
/**
* The operation catalogue the composer builds a pipeline from. Each entry's
* `operation` is a real Stirling endpoint path (the backend's PipelineStep
* contract) with sensible default `parameters`, so a composed pipeline is
* runnable as-is. Per-operation parameter editing is intentionally out of scope
* for this version: the composer chains operations with their defaults. Labels
* are derived from the path, so adding an operation needs no translation work.
*/
export interface PipelineOperationDef {
/** Stirling endpoint path, e.g. "/api/v1/misc/compress-pdf". */
operation: string;
/** Scalar form fields the endpoint accepts; defaults that keep the step valid. */
parameters: Record<string, unknown>;
}
export const PIPELINE_OPERATIONS: PipelineOperationDef[] = [
{ operation: "/api/v1/misc/ocr-pdf", parameters: {} },
{ operation: "/api/v1/misc/compress-pdf", parameters: {} },
{ operation: "/api/v1/misc/flatten", parameters: {} },
{ operation: "/api/v1/misc/repair", parameters: {} },
{
operation: "/api/v1/security/auto-redact",
parameters: { mode: "automatic", convertPDFToImage: true },
},
{
operation: "/api/v1/security/sanitize-pdf",
parameters: { removeJavaScript: true },
},
{ operation: "/api/v1/security/add-watermark", parameters: {} },
{ operation: "/api/v1/security/add-password", parameters: {} },
{ operation: "/api/v1/security/remove-password", parameters: {} },
{ operation: "/api/v1/general/merge-pdfs", parameters: {} },
{ operation: "/api/v1/misc/add-stamp", parameters: {} },
{ operation: "/api/v1/misc/add-page-numbers", parameters: {} },
];
/**
* Turn an endpoint path into a display label: take the last segment, drop the
* "pdf"/"pdfs" filler words, and title-case the rest.
* "/api/v1/misc/compress-pdf" → "Compress"; "/api/v1/security/auto-redact" →
* "Auto Redact". Works for any operation, including ones loaded from the backend
* that aren't in the catalogue above.
*/
export function humanizeOperation(path: string): string {
const last = path.split("/").filter(Boolean).pop() ?? path;
const words = last.split("-").filter((w) => w !== "pdf" && w !== "pdfs");
const base = (words.length > 0 ? words : [last]).join(" ");
return base.replace(/\b\w/g, (c) => c.toUpperCase()).trim();
}
@@ -1,35 +0,0 @@
import type { StageKey } from "@portal/api/pipelines";
import type { OpKind } from "@shared/data/ops";
/**
* Fixed accent per stage so the chip row reads the same across every pipeline.
* Each value is also a valid {@link import("@shared/components").ChipTone}, so
* it doubles as the chip tone wherever a stage colour is rendered.
*/
export type StageAccent = "green" | "blue" | "amber" | "red" | "purple";
export const STAGE_ACCENT: Record<StageKey, StageAccent> = {
ingest: "green",
validate: "blue",
modify: "amber",
secure: "red",
route: "purple",
};
/** Op-kind accent mirrors the stage palette; alerts share the route colour. */
export const OP_KIND_ACCENT: Record<OpKind, StageAccent> = {
ingest: "green",
validate: "blue",
modify: "amber",
secure: "red",
store: "purple",
alert: "purple",
};
export const STAGE_COLOR_VAR: Record<StageAccent, string> = {
green: "var(--color-green)",
blue: "var(--color-blue)",
amber: "var(--color-amber)",
red: "var(--color-red)",
purple: "var(--color-purple)",
};
@@ -1,102 +0,0 @@
import type { Pipeline, PromotedPipeline } from "@portal/api/pipelines";
/** Sample pipelines shared by the Pipelines component stories. */
export const HEALTHY_PIPELINE: Pipeline = {
id: "pl-invoice-ap",
name: "Invoice → AP",
blurb: "Invoice extraction → three-way match → Postgres",
status: "healthy",
source: "S3 bucket watch",
destination: "Database",
version: "v2.8.0",
regions: ["us-east-1", "eu-west-1", "ap-southeast-1"],
metrics: {
docs24h: 53120,
throughputPerMin: 41,
errorRate: 0.006,
p95LatencyMs: 358,
uptime: 0.9997,
},
stages: [
{ key: "ingest", label: "Ingest", ops: ["Parse", "Classify", "Extract"] },
{
key: "validate",
label: "Validate",
ops: ["Schema validate", "Confidence bounds"],
},
{ key: "modify", label: "Modify", ops: ["PDF → CSV"] },
{
key: "secure",
label: "Secure",
ops: ["Redact PII", "Encryption at rest"],
},
{ key: "route", label: "Route / Store", ops: ["Primary store", "Notify"] },
],
golden: { passing: 42, total: 42, lastRun: "1h ago", threshold: 0.95 },
drift: [],
};
export const DEGRADED_PIPELINE: Pipeline = {
...HEALTHY_PIPELINE,
id: "pl-prior-auth",
name: "Prior Auth Router",
blurb: "Prior-authorization intake → medical-necessity gate → payer webhook",
status: "degraded",
source: "Inbound webhook",
destination: "Outbound webhook",
version: "v3.1.0",
regions: ["us-east-1"],
metrics: {
docs24h: 11204,
throughputPerMin: 9,
errorRate: 0.031,
p95LatencyMs: 740,
uptime: 0.9962,
},
golden: { passing: 24, total: 28, lastRun: "47m ago", threshold: 0.9 },
drift: [
{
field: "procedure_codes",
note: "New CPT modifier suffix not seen in prior examples",
confidenceDelta: -0.07,
severity: "warning",
affectedDocs: 18,
},
{
field: "payer",
note: "Two payers now emit a merged-entity name",
confidenceDelta: -0.03,
severity: "info",
affectedDocs: 6,
},
],
};
/** Sample watch-folder-promoted flows for the PromotedPipelines stories. */
export const PROMOTED_PIPELINES: PromotedPipeline[] = [
{
id: "pl-promo-statements",
name: "Bank Statement Normalizer",
sourceDocType: "Bank statement",
watchFolder: "~/StirlingWatch/statements-in",
status: "deployed",
promotedAt: "promoted 3d ago",
},
{
id: "pl-promo-receipts",
name: "Receipt Splitter",
sourceDocType: "Expense receipt",
watchFolder: "~/StirlingWatch/receipts",
status: "staged",
promotedAt: "promoted 11h ago",
},
{
id: "pl-promo-onboarding",
name: "New-Hire Packet Sorter",
sourceDocType: "Onboarding packet",
watchFolder: "\\\\hr-share\\NewHireScans",
status: "review",
promotedAt: "promoted 2h ago",
},
];
+203 -11
View File
@@ -1,20 +1,212 @@
import { http, HttpResponse, delay } from "msw";
import type { Tier } from "@portal/contexts/TierContext";
import { pipelinesFor } from "@portal/mocks/pipelines";
import type {
PipelineKpi,
PipelineStatus,
PipelineView,
PipelinesOverviewResponse,
Policy,
} from "@portal/api/pipelines";
/**
* Stateful mock for the Pipelines surface so the portal works fully offline with
* mocks on. Mirrors the real backend shape (`/api/v1/policies`, PolicyController +
* PolicyOverviewService): the overview, create, edit, and delete mutate an
* in-memory store of real-shaped policies. With mocks OFF these calls fall through
* to the real backend instead.
*
* The user-facing Policies "catalogue" page also lives on `/api/v1/policies` (its
* own handlers, a different response shape). These handlers are registered first
* and DISCRIMINATE: anything that isn't a real-shaped pipeline (the catalogue
* page's bodies carry a `categoryId`; its ids aren't in this store) is passed
* through by returning nothing, so the catalogue handlers still serve it. That
* keeps both surfaces working in mock mode without either clobbering the other.
*/
/** Display names for the seeded sources, so the overview resolves ids to names. */
const SOURCE_NAMES: Record<string, string> = {
"src-claims": "Claims intake",
"src-contracts": "Contracts drop",
"src-archive": "Archive reprocess",
};
interface StoredPolicy extends Policy {
id: string;
}
function seedPipelines(): StoredPolicy[] {
return [
{
id: "plc-redaction",
name: "Redaction sweep",
owner: "security@acme.com",
enabled: true,
trigger: {
type: "schedule",
options: { schedule: { type: "every", count: 6, unit: "HOURS" } },
},
sourceIds: ["src-claims"],
steps: [
{
operation: "/api/v1/security/auto-redact",
parameters: { mode: "automatic", convertPDFToImage: true },
},
{ operation: "/api/v1/security/sanitize-pdf", parameters: {} },
],
output: { type: "inline", options: {} },
},
{
id: "plc-archive",
name: "Archive compressor",
owner: "data-eng@acme.com",
enabled: true,
trigger: null,
sourceIds: ["src-contracts", "src-archive"],
steps: [{ operation: "/api/v1/misc/compress-pdf", parameters: {} }],
output: { type: "folder", options: { directory: "/data/archive-out" } },
},
{
id: "plc-onboarding",
name: "Onboarding OCR (paused)",
owner: "ops@acme.com",
enabled: false,
trigger: null,
sourceIds: [],
steps: [
{ operation: "/api/v1/misc/ocr-pdf", parameters: {} },
{ operation: "/api/v1/misc/flatten", parameters: {} },
],
output: { type: "inline", options: {} },
},
];
}
let store: StoredPolicy[] = seedPipelines();
let idCounter = 0;
function nextId(): string {
idCounter += 1;
return `plc_${Date.now().toString(36)}_${idCounter}`;
}
function deriveStatus(policy: StoredPolicy): PipelineStatus {
return policy.enabled ? "active" : "paused";
}
function toView(policy: StoredPolicy): PipelineView {
return {
id: policy.id,
name: policy.name,
enabled: policy.enabled,
status: deriveStatus(policy),
trigger: policy.trigger?.type ?? "manual",
sources: policy.sourceIds.map((id) => ({
id,
name: SOURCE_NAMES[id] ?? id,
})),
steps: policy.steps.map((s) => s.operation),
output: policy.output?.type ?? "inline",
owner: policy.owner ?? "you@acme.com",
};
}
function buildKpis(): PipelineKpi[] {
const total = store.length;
const active = store.filter((p) => p.enabled).length;
return [
{ value: total, description: "pipelines" },
{ value: active, description: "running automatically" },
{ value: total - active, description: "paused" },
];
}
function buildOverview(): PipelinesOverviewResponse {
const pipelines = store
.map(toView)
.sort((a, b) => a.name.localeCompare(b.name));
return { kpis: buildKpis(), pipelines };
}
export const pipelinesHandlers = [
http.get("/v1/pipelines", async ({ request }) => {
http.get("/api/v1/policies/overview", async () => {
await delay(120);
const url = new URL(request.url);
const tier = (url.searchParams.get("tier") ?? "pro") as Tier;
return HttpResponse.json(pipelinesFor(tier));
return HttpResponse.json(buildOverview());
}),
// Accepts the promote-to-policy submit so the UI can resolve. The real
// backend would create a policy from the pipeline's stages; here it just
// acknowledges. See TODO(backend) on api/pipelines.ts promoteToPolicy.
http.post("/v1/pipelines/:id/promote-to-policy", async () => {
// Available triggers + their source-type compatibility. Registered before the
// ":id" handler so "triggers" isn't matched as a policy id.
http.get("/api/v1/policies/triggers", async () => {
await delay(120);
return HttpResponse.json({ ok: true });
return HttpResponse.json([
{ type: "schedule", requiresSource: false, supportedSourceTypes: [] },
{
type: "folder-watch",
requiresSource: true,
supportedSourceTypes: ["folder"],
},
]);
}),
// Run status: the mock completes runs immediately, so polling resolves at once.
http.get("/api/v1/policies/run/:runId", async ({ params }) => {
await delay(120);
return HttpResponse.json({
runId: String(params.runId),
policyId: null,
status: "COMPLETED",
currentStep: 1,
stepCount: 1,
error: null,
errorCode: null,
createdAt: Date.now(),
});
}),
// Manual trigger: pretends to start one run and returns its id to poll.
http.post("/api/v1/policies/:id/trigger", async ({ params }) => {
if (!store.some((p) => p.id === params.id)) return undefined;
await delay(120);
return HttpResponse.json([`run_${Date.now().toString(36)}`]);
}),
// Raw policy by id. Only our pipeline ids are served here; everything else falls
// through to the catalogue page's handler.
http.get("/api/v1/policies/:id", async ({ params }) => {
const policy = store.find((p) => p.id === params.id);
if (!policy) return undefined;
await delay(120);
return HttpResponse.json(policy);
}),
// Create or update a pipeline. The catalogue page's bodies carry a `categoryId`;
// those are passed through so its own handler stores them.
http.post("/api/v1/policies", async ({ request }) => {
// Clone before reading: a non-pipeline body falls through to the catalogue
// page's handler, which needs to read the (still-unconsumed) body itself.
const incoming = (await request.clone().json()) as Policy & {
categoryId?: string;
};
if ("categoryId" in incoming) return undefined;
await delay(120);
const existing = incoming.id
? store.find((p) => p.id === incoming.id)
: undefined;
const id = existing?.id ?? nextId();
const saved: StoredPolicy = {
...incoming,
id,
owner: existing?.owner ?? "you@acme.com",
};
store = existing
? store.map((p) => (p.id === id ? saved : p))
: [...store, saved];
return HttpResponse.json(saved);
}),
http.delete("/api/v1/policies/:id", async ({ params }) => {
const id = String(params.id);
if (!store.some((p) => p.id === id)) return undefined;
await delay(120);
store = store.filter((p) => p.id !== id);
return new HttpResponse(null, { status: 204 });
}),
];
-415
View File
@@ -1,415 +0,0 @@
/**
* Pipelines fixtures and the types api/pipelines.ts shares with them.
* api/pipelines.ts imports the types; the MSW handlers in mocks/handlers/
* serve the fixture data over the intercepted apiClient.local.json() calls. Components
* never reach into this module directly.
*
* Fixtures are tier-shaped:
* - free → empty (prompts "build your first pipeline")
* - pro → a small deployed fleet
* - enterprise → a larger fleet plus a shadow/comparative evals note
*/
import type { Tier } from "@portal/contexts/TierContext";
/** A deployed pipeline's live health. */
export type PipelineStatus = "healthy" | "degraded";
/** 24-hour rollup shown on the pipeline row. */
export interface PipelineMetrics {
/** Docs processed in the trailing 24h. */
docs24h: number;
/** Sustained throughput, docs/min. */
throughputPerMin: number;
/** Error rate as a fraction (0.004 = 0.4%). */
errorRate: number;
/** P95 stage-to-store latency, ms. */
p95LatencyMs: number;
/** Uptime over the trailing 24h, as a fraction. */
uptime: number;
}
/**
* The five silent stages every pipeline passes a document through. The accent
* is fixed per stage so the chip row reads the same across every pipeline:
* Ingest=green, Validate=blue, Modify=amber, Secure=red, Route/Store=purple.
*/
export type StageKey = "ingest" | "validate" | "modify" | "secure" | "route";
export interface StageSummary {
key: StageKey;
label: string;
/** Op labels active in this stage for this pipeline. */
ops: string[];
}
/** Golden-set validation rollup. */
export interface GoldenSet {
passing: number;
total: number;
/** Last time the set was run. */
lastRun: string;
/**
* Minimum pass rate (fraction) this pipeline must hold to be considered
* reliable. A pipeline below its own bound is amber/red at a glance — the
* bound is per-pipeline because a clause-risk pipeline tolerates less slack
* than a high-volume extraction one.
*/
threshold: number;
}
/** A single field whose shape has drifted from the inferred schema. */
export interface SchemaDrift {
field: string;
/** Human summary of what changed. */
note: string;
/** Confidence delta since the last known-good shape (negative = worse). */
confidenceDelta: number;
severity: "info" | "warning";
/** Share of docs in the window that exhibited the drift. */
affectedDocs: number;
}
export interface Pipeline {
id: string;
name: string;
/** What the pipeline does, one line. */
blurb: string;
status: PipelineStatus;
/** Source rail label (from SOURCE_OPTIONS). */
source: string;
/** Destination rail label (from DESTINATION_OPTIONS). */
destination: string;
/** Deployed version tag. */
version: string;
/** Regions the pipeline runs in. */
regions: string[];
metrics: PipelineMetrics;
stages: StageSummary[];
golden: GoldenSet;
drift: SchemaDrift[];
}
const STAGE_LABEL: Record<StageKey, string> = {
ingest: "Ingest",
validate: "Validate",
modify: "Modify",
secure: "Secure",
route: "Route / Store",
};
/** Build a five-stage summary from the per-stage op-label lists. */
function stages(
ingest: string[],
validate: string[],
modify: string[],
secure: string[],
route: string[],
): StageSummary[] {
const byKey: Record<StageKey, string[]> = {
ingest,
validate,
modify,
secure,
route,
};
return (Object.keys(byKey) as StageKey[]).map((key) => ({
key,
label: STAGE_LABEL[key],
ops: byKey[key],
}));
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Tier fixtures */
/* ──────────────────────────────────────────────────────────────────────── */
const COI_COMPLIANCE: Pipeline = {
id: "pl-coi",
name: "COI Compliance",
blurb: "Certificate-of-insurance intake → coverage-gap check → vault",
status: "healthy",
source: "Email intake",
destination: "Stirling vault",
version: "v3.4.1",
regions: ["us-east-1", "eu-west-1"],
metrics: {
docs24h: 28941,
throughputPerMin: 22,
errorRate: 0.004,
p95LatencyMs: 412,
uptime: 0.9998,
},
stages: stages(
["OCR", "Classify", "Extract"],
["Schema validate", "Confidence bounds"],
["Compress"],
["Redact PII", "Encryption at rest"],
["Primary store", "Processing manifest"],
),
golden: { passing: 36, total: 36, lastRun: "2h ago", threshold: 0.95 },
drift: [],
};
const PRIOR_AUTH: Pipeline = {
id: "pl-prior-auth",
name: "Prior Auth Router",
blurb: "Prior-authorization intake → medical-necessity gate → payer webhook",
status: "degraded",
source: "Inbound webhook",
destination: "Outbound webhook",
version: "v3.1.0",
regions: ["us-east-1"],
metrics: {
docs24h: 11204,
throughputPerMin: 9,
errorRate: 0.031,
p95LatencyMs: 740,
uptime: 0.9962,
},
stages: stages(
["OCR", "Classify", "Extract"],
["Schema validate", "Counterparty match", "Confidence bounds"],
["Convert"],
["Redact PII", "PII/PHI enforcement", "Encryption at rest"],
["Conditional routing", "Human review"],
),
// Below its own 0.90 bound (24/28 ≈ 0.857) — the at-a-glance reliability miss.
golden: { passing: 24, total: 28, lastRun: "47m ago", threshold: 0.9 },
drift: [
{
field: "procedure_codes",
note: "New CPT modifier suffix not seen in prior examples",
confidenceDelta: -0.07,
severity: "warning",
affectedDocs: 18,
},
{
field: "payer",
note: "Two payers now emit a merged-entity name",
confidenceDelta: -0.03,
severity: "info",
affectedDocs: 6,
},
],
};
const INVOICE_AP: Pipeline = {
id: "pl-invoice-ap",
name: "Invoice → AP",
blurb: "Invoice extraction → three-way match → Postgres",
status: "healthy",
source: "S3 bucket watch",
destination: "Database",
version: "v2.8.0",
regions: ["us-east-1", "eu-west-1", "ap-southeast-1"],
metrics: {
docs24h: 53120,
throughputPerMin: 41,
errorRate: 0.006,
p95LatencyMs: 358,
uptime: 0.9997,
},
stages: stages(
["Parse", "Classify", "Extract"],
["Schema validate", "Confidence bounds"],
["PDF → CSV"],
["Redact PII", "Encryption at rest"],
["Primary store", "Mirror to bucket", "Notify"],
),
golden: { passing: 41, total: 42, lastRun: "1h ago", threshold: 0.95 },
drift: [
{
field: "tax",
note: "EU reverse-charge invoices omit a line-level tax field",
confidenceDelta: -0.02,
severity: "info",
affectedDocs: 4,
},
],
};
const CONTRACT_REVIEW: Pipeline = {
id: "pl-contract",
name: "Contract Review",
blurb: "Contract intake → clause-risk analysis → review queue",
status: "healthy",
source: "Upload API",
destination: "Another pipeline",
version: "v1.9.2",
regions: ["eu-west-1"],
metrics: {
docs24h: 4380,
throughputPerMin: 4,
errorRate: 0.009,
p95LatencyMs: 1280,
uptime: 0.9991,
},
stages: stages(
["OCR", "Classify"],
["Contract analyzer", "Authenticity", "Confidence bounds"],
["Document summarizer"],
["Redact PII", "Confidentiality mark", "Signed outputs"],
["Human review", "Flag"],
),
golden: { passing: 31, total: 33, lastRun: "5h ago", threshold: 0.95 },
drift: [],
};
const KYC_PROCESSOR: Pipeline = {
id: "pl-kyc",
name: "KYC Processor",
blurb:
"Identity-document intake → authenticity + sanctions → compliance archive",
status: "healthy",
source: "Scheduled import",
destination: "Compliance archive",
version: "v4.0.3",
regions: ["us-east-1", "eu-west-1", "ap-southeast-1"],
metrics: {
docs24h: 19870,
throughputPerMin: 15,
errorRate: 0.005,
p95LatencyMs: 503,
uptime: 0.9999,
},
stages: stages(
["OCR", "Classify", "Extract"],
["Authenticity", "Tamper check", "Counterparty match"],
["Convert"],
["Field-aware redact", "Attribution watermark", "Encryption at rest"],
["Compliance archive", "Processing manifest", "Notify"],
),
golden: { passing: 52, total: 54, lastRun: "31m ago", threshold: 0.95 },
drift: [
{
field: "document_number",
note: "New passport series uses a 9-char alphanumeric format",
confidenceDelta: -0.04,
severity: "warning",
affectedDocs: 11,
},
],
};
/**
* Enterprise-only evals note. Surfaced as a banner: shadow + comparative eval
* runs gate every promotion, so the deployed fleet always trails a quietly
* running candidate.
*/
export interface EvalsNote {
/** Pipelines currently running a shadow eval against production traffic. */
shadowCount: number;
/** Comparative (champion/challenger) runs awaiting sign-off. */
comparativeCount: number;
detail: string;
}
const ENTERPRISE_EVALS: EvalsNote = {
shadowCount: 2,
comparativeCount: 2,
detail:
"Prior Auth v3.2.0-rc and Invoice v2.9.0-rc are mirroring live traffic in shadow; KYC v4.1.0 is in a comparative run against v4.0.3. The Contract Review v2.0.0 candidate is blocked — it regressed 3 golden cases on clause-risk scoring, so the comparative run is held until the candidate is re-cut. No promotion happens until a candidate clears its golden set and the comparative delta stays inside bounds.",
};
/**
* A pipeline that began life as an Editor watch-folder flow and was promoted
* into the portal. These are the on-ramp from ad-hoc desktop automation to a
* governed, deployed pipeline — they keep a pointer back to the watch folder
* they grew out of so the lineage stays visible.
*/
export interface PromotedPipeline {
id: string;
name: string;
/** Doc type the originating watch-folder flow was built around. */
sourceDocType: string;
/** The Editor watch folder this was promoted from. */
watchFolder: string;
/** Where the promotion sits in its lifecycle. */
status: PromotedStatus;
/** When the promotion landed. */
promotedAt: string;
}
/**
* A promoted flow is `deployed` once it runs in the portal, `staged` while it
* mirrors the watch folder without taking over, and `review` when it needs a
* human to confirm the flow before it goes live.
*/
export type PromotedStatus = "deployed" | "staged" | "review";
export interface PipelinesResponse {
pipelines: Pipeline[];
/** Present for enterprise only. */
evals: EvalsNote | null;
/** Flows promoted up from Editor watch folders. Empty on free. */
promoted: PromotedPipeline[];
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Promoted-from-Editor fixtures */
/* ──────────────────────────────────────────────────────────────────────── */
const PROMOTED_PRO: PromotedPipeline[] = [
{
id: "pl-promo-statements",
name: "Bank Statement Normalizer",
sourceDocType: "Bank statement",
watchFolder: "~/StirlingWatch/statements-in",
status: "deployed",
promotedAt: "promoted 3d ago",
},
{
id: "pl-promo-receipts",
name: "Receipt Splitter",
sourceDocType: "Expense receipt",
watchFolder: "~/StirlingWatch/receipts",
status: "staged",
promotedAt: "promoted 11h ago",
},
];
const PROMOTED_ENTERPRISE: PromotedPipeline[] = [
...PROMOTED_PRO,
{
id: "pl-promo-claims",
name: "Claims Intake Splitter",
sourceDocType: "Insurance claim",
watchFolder: "\\\\fileserver\\ClaimsDropbox",
status: "deployed",
promotedAt: "promoted 6d ago",
},
{
id: "pl-promo-onboarding",
name: "New-Hire Packet Sorter",
sourceDocType: "Onboarding packet",
watchFolder: "\\\\hr-share\\NewHireScans",
status: "review",
promotedAt: "promoted 2h ago",
},
];
export function pipelinesFor(tier: Tier): PipelinesResponse {
if (tier === "free") {
return { pipelines: [], evals: null, promoted: [] };
}
if (tier === "enterprise") {
return {
pipelines: [
INVOICE_AP,
COI_COMPLIANCE,
PRIOR_AUTH,
KYC_PROCESSOR,
CONTRACT_REVIEW,
],
evals: ENTERPRISE_EVALS,
promoted: PROMOTED_ENTERPRISE,
};
}
// pro
return {
pipelines: [COI_COMPLIANCE, INVOICE_AP, PRIOR_AUTH],
evals: null,
promoted: PROMOTED_PRO,
};
}
+235 -529
View File
@@ -8,7 +8,7 @@
}
/* Header */
.portal-pipelines__header {
.portal-pipelines__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
@@ -30,574 +30,280 @@
max-width: 46rem;
}
/* Fleet summary chips */
.portal-pipelines__fleet {
/* Table cells */
.portal-pipelines__name-cell {
display: flex;
align-items: center;
gap: 0.5rem;
}
.portal-pipelines__fleet-count {
font-size: 0.75rem;
color: var(--color-text-4);
}
/* List */
.portal-pipelines__list {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.portal-pipelines__card {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.portal-pipelines__card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.portal-pipelines__card-title {
margin: 0;
font-size: 1.0625rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-pipelines__card-blurb {
margin: 0.125rem 0 0;
font-size: 0.8125rem;
color: var(--color-text-3);
}
/* Source → stages → destination rail */
.portal-pipelines__card-rail {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.portal-pipelines__rail-chip {
font-size: 0.6875rem;
font-weight: 500;
color: var(--color-text-3);
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-pill);
padding: 0.1875rem 0.5rem;
}
.portal-pipelines__rail-arrow {
color: var(--color-text-5);
font-size: 0.75rem;
}
.portal-pipelines__stage-dots {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.portal-pipelines__stage-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
}
/* Metric strip on the card */
.portal-pipelines__metrics {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.5rem;
padding: 0.625rem 0;
border-top: 1px solid var(--color-border-light);
border-bottom: 1px solid var(--color-border-light);
}
@media (max-width: 50rem) {
.portal-pipelines__metrics {
grid-template-columns: repeat(2, 1fr);
}
}
.portal-pipelines__card-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
font-size: 0.6875rem;
color: var(--color-text-4);
}
.portal-pipelines__card-version {
font-family: var(--font-mono);
}
.portal-pipelines__card-golden {
color: var(--color-text-3);
}
.portal-pipelines__card-drift {
color: var(--color-amber-dark);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Detail drawer */
/* ──────────────────────────────────────────────────────────────────────── */
.portal-pipelines__detail {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.portal-pipelines__detail-metrics {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.625rem;
padding: 0.875rem;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
}
.portal-pipelines__detail-section {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.portal-pipelines__detail-h {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-pipelines__detail-sub {
margin: -0.375rem 0 0;
font-size: 0.75rem;
color: var(--color-text-4);
}
/* The five silent stages */
.portal-pipelines__stages {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.portal-pipelines__stage {
padding: 0.625rem 0.75rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.portal-pipelines__stage-head {
display: flex;
align-items: center;
gap: 0.4375rem;
margin-bottom: 0.5rem;
}
.portal-pipelines__stage-pip {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
}
.portal-pipelines__stage-name {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-2);
}
.portal-pipelines__stage-chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.portal-pipelines__stage-empty {
font-size: 0.75rem;
color: var(--color-text-5);
font-style: italic;
}
/* Golden set */
.portal-pipelines__golden {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
}
.portal-pipelines__golden-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.portal-pipelines__golden-when {
font-size: 0.6875rem;
color: var(--color-text-4);
}
/* Schema drift */
.portal-pipelines__drift-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.portal-pipelines__drift {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 0.75rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.portal-pipelines__drift-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
}
.portal-pipelines__drift-text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.portal-pipelines__drift-field {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-1);
font-family: var(--font-mono);
}
.portal-pipelines__drift-note {
font-size: 0.75rem;
color: var(--color-text-4);
}
.portal-pipelines__drift-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.125rem;
font-size: 0.6875rem;
color: var(--color-text-4);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Composer modal */
/* ──────────────────────────────────────────────────────────────────────── */
.portal-pipelines__composer {
min-height: 22rem;
}
.portal-pipelines__composer-body {
display: flex;
flex-direction: column;
gap: 1rem;
}
.portal-pipelines__composer-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.625rem;
}
@media (max-width: 44rem) {
.portal-pipelines__composer-grid {
grid-template-columns: 1fr;
}
}
.portal-pipelines__option {
.portal-pipelines__name-text {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem;
text-align: left;
background: var(--color-surface);
border: 1px solid var(--color-border);
min-width: 0;
}
.portal-pipelines__name-text strong {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-pipelines__pipe-dot {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.875rem;
height: 1.875rem;
flex-shrink: 0;
border-radius: var(--radius-md);
cursor: pointer;
transition:
border-color var(--motion-fast),
background var(--motion-fast);
}
.portal-pipelines__option:hover {
border-color: var(--color-border-hover);
background: var(--color-bg-hover);
}
.portal-pipelines__option.is-selected {
border-color: var(--color-blue);
font-size: 0.875rem;
background: var(--color-blue-light);
}
.portal-pipelines__option-label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-pipelines__option-desc {
font-size: 0.6875rem;
color: var(--color-text-4);
line-height: 1.4;
}
/* Op chain summary */
.portal-pipelines__chain {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
}
.portal-pipelines__chain-label,
.portal-pipelines__agents-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-3);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.portal-pipelines__chain-chips,
.portal-pipelines__agents-row {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.portal-pipelines__chain-empty {
font-size: 0.75rem;
color: var(--color-text-5);
font-style: italic;
}
.portal-pipelines__agents {
display: flex;
flex-direction: column;
gap: 0.4375rem;
}
/* Op library picker */
.portal-pipelines__library {
display: flex;
flex-direction: column;
gap: 0.875rem;
max-height: 19rem;
overflow-y: auto;
padding: 0.75rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.portal-pipelines__library-group {
display: flex;
flex-direction: column;
gap: 0.4375rem;
}
.portal-pipelines__library-head {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-2);
}
.portal-pipelines__library-pip {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
}
.portal-pipelines__library-chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
/* Routing alerts */
.portal-pipelines__alerts {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.portal-pipelines__alert {
display: grid;
grid-template-columns: auto 1fr;
align-items: start;
gap: 0.625rem;
padding: 0.625rem 0.75rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
}
.portal-pipelines__alert input {
margin-top: 0.1875rem;
accent-color: var(--color-blue);
}
.portal-pipelines__alert > span {
display: flex;
flex-direction: column;
gap: 0.0625rem;
}
.portal-pipelines__alert strong {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-pipelines__alert span span {
font-size: 0.6875rem;
color: var(--color-text-4);
}
/* Composer step indicator in the footer */
.portal-pipelines__composer-steps {
display: flex;
align-items: center;
gap: 0.625rem;
margin-right: auto;
}
.portal-pipelines__composer-step {
font-size: 0.6875rem;
font-weight: 500;
color: var(--color-text-5);
}
.portal-pipelines__composer-step.is-active {
color: var(--color-blue);
font-weight: 600;
}
.portal-pipelines__composer-step.is-done {
color: var(--color-green-dark);
.portal-pipelines__muted {
font-size: 0.8125rem;
color: var(--color-text-4);
margin: 0;
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Roster + promoted sections */
/* ──────────────────────────────────────────────────────────────────────── */
.portal-pipelines__caret {
display: inline-block;
color: var(--color-text-5);
transition: transform var(--motion-fast);
}
.portal-pipelines__section {
.portal-pipelines__caret.is-open {
transform: rotate(90deg);
color: var(--color-blue);
}
/* Expanded detail panel */
.portal-pipelines__expanded {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: 1.25rem;
animation: portal-pipelines-reveal var(--motion-fast) ease-out;
}
@keyframes portal-pipelines-reveal {
from {
opacity: 0;
transform: translateY(-0.25rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.portal-pipelines__expanded-head {
display: flex;
flex-direction: column;
gap: 0.625rem;
align-items: center;
gap: 0.75rem;
padding-bottom: 0.875rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--color-border-light);
}
.portal-pipelines__section-h {
.portal-pipelines__expanded-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-pipelines__section-sub {
margin: -0.25rem 0 0.25rem;
.portal-pipelines__expanded-sub {
font-size: 0.75rem;
color: var(--color-text-4);
max-width: 46rem;
}
/* Golden-set roster table */
.portal-pipelines__roster-name,
.portal-pipelines__promoted-name {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
.portal-pipelines__expanded-close {
margin-left: auto;
width: 1.75rem;
height: 1.75rem;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-4);
font-size: 1.125rem;
line-height: 1;
cursor: pointer;
transition:
background var(--motion-fast),
color var(--motion-fast);
}
.portal-pipelines__roster-name strong,
.portal-pipelines__promoted-name strong {
font-size: 0.8125rem;
font-weight: 600;
.portal-pipelines__expanded-close:hover {
background: var(--color-bg-hover);
color: var(--color-text-1);
}
.portal-pipelines__roster-route,
.portal-pipelines__promoted-when {
font-size: 0.6875rem;
color: var(--color-text-4);
/* Detail body */
.portal-pipelines__detail {
display: flex;
flex-direction: column;
gap: 1.125rem;
}
.portal-pipelines__roster-golden {
.portal-pipelines__detail-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.portal-pipelines__detail-heading {
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-5);
font-weight: 600;
}
.portal-pipelines__chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.portal-pipelines__detail-actions {
display: flex;
gap: 0.5rem;
padding-top: 0.875rem;
margin-top: 1rem;
border-top: 1px solid var(--color-border-light);
}
/* Table skeleton */
.portal-pipelines__table-skeleton {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Composer */
/* ──────────────────────────────────────────────────────────────────────── */
.portal-pipelines__composer {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.portal-pipelines__composer-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.portal-pipelines__composer-footer {
display: flex;
justify-content: space-between;
gap: 0.5rem;
width: 100%;
}
.portal-pipelines__source-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 12rem;
overflow-y: auto;
padding: 0.625rem;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
}
/* Operation chain */
.portal-pipelines__chain {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.portal-pipelines__chain-row {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.4375rem 0.625rem;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
}
.portal-pipelines__chain-index {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
border-radius: 50%;
font-size: 0.6875rem;
font-weight: 600;
background: var(--color-blue-light);
color: var(--color-blue);
}
.portal-pipelines__chain-op {
flex: 1;
font-size: 0.8125rem;
color: var(--color-text-1);
}
.portal-pipelines__chain-actions {
display: flex;
gap: 0.25rem;
}
.portal-pipelines__chain-actions button {
width: 1.5rem;
height: 1.5rem;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-3);
font-size: 0.8125rem;
line-height: 1;
cursor: pointer;
transition:
background var(--motion-fast),
color var(--motion-fast);
}
.portal-pipelines__chain-actions button:hover:not(:disabled) {
background: var(--color-bg-hover);
color: var(--color-text-1);
}
.portal-pipelines__chain-actions button:disabled {
opacity: 0.4;
cursor: default;
}
.portal-pipelines__op-palette {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
/* Schedule row */
.portal-pipelines__schedule {
display: flex;
align-items: center;
gap: 0.5rem;
}
.portal-pipelines__roster-rate {
font-size: 0.6875rem;
color: var(--color-text-4);
font-variant-numeric: tabular-nums;
}
.portal-pipelines__roster-num,
.portal-pipelines__roster-version {
font-variant-numeric: tabular-nums;
color: var(--color-text-2);
}
.portal-pipelines__roster-version {
font-family: var(--font-mono);
font-size: 0.75rem;
}
/* Promoted-from-Editor table */
.portal-pipelines__promoted-muted {
color: var(--color-text-3);
font-size: 0.8125rem;
}
.portal-pipelines__promoted-folder {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--color-text-3);
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-sm);
padding: 0.0625rem 0.375rem;
.portal-pipelines__schedule-count {
width: 5rem;
}
@@ -0,0 +1,245 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { HttpError } from "@portal/api/http";
import type { PipelinesOverviewResponse, Policy } from "@portal/api/pipelines";
import type { SourcesResponse } from "@portal/api/sources";
import { Pipelines } from "@portal/views/Pipelines";
// Deterministic i18n: keys returned verbatim, so assertions are stable without
// the async TOML backend.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { changeLanguage: vi.fn() },
}),
}));
const fetchPipelines = vi.fn();
const fetchPipeline = vi.fn();
const savePipeline = vi.fn();
const deletePipeline = vi.fn();
const fetchTriggers = vi.fn();
const triggerPipeline = vi.fn();
const fetchRun = vi.fn();
vi.mock("@portal/api/pipelines", () => ({
fetchPipelines: () => fetchPipelines(),
fetchPipeline: (id: string) => fetchPipeline(id),
savePipeline: (policy: unknown) => savePipeline(policy),
deletePipeline: (id: string) => deletePipeline(id),
fetchTriggers: () => fetchTriggers(),
triggerPipeline: (id: string) => triggerPipeline(id),
fetchRun: (runId: string) => fetchRun(runId),
}));
const fetchSources = vi.fn();
vi.mock("@portal/api/sources", () => ({
fetchSources: () => fetchSources(),
}));
const RESPONSE: PipelinesOverviewResponse = {
kpis: [
{ value: 2, description: "" },
{ value: 2, description: "" },
{ value: 0, description: "" },
],
pipelines: [
{
id: "plc-redaction",
name: "Redaction sweep",
enabled: true,
status: "active",
trigger: "schedule",
sources: [{ id: "src-claims", name: "Claims intake" }],
steps: ["/api/v1/security/auto-redact"],
output: "inline",
owner: "security@acme.com",
},
{
id: "plc-archive",
name: "Archive compressor",
enabled: true,
status: "active",
trigger: "manual",
sources: [],
steps: ["/api/v1/misc/compress-pdf"],
output: "folder",
owner: "data@acme.com",
},
],
};
const RAW_REDACTION: Policy = {
id: "plc-redaction",
name: "Redaction sweep",
enabled: true,
trigger: {
type: "schedule",
options: { schedule: { type: "every", count: 6, unit: "HOURS" } },
},
sourceIds: ["src-claims"],
steps: [{ operation: "/api/v1/security/auto-redact", parameters: {} }],
output: { type: "inline", options: {} },
};
const SOURCES: SourcesResponse = {
kpis: [],
sources: [
{
id: "src-claims",
name: "Claims intake",
type: "folder",
status: "active",
referenceCount: 1,
referencingPolicies: [],
config: [],
docsTotal: null,
},
],
};
function renderView() {
return render(
<MemoryRouter>
<Pipelines />
</MemoryRouter>,
);
}
describe("Pipelines view", () => {
beforeEach(() => {
fetchPipelines.mockReset();
fetchPipeline.mockReset();
savePipeline.mockReset();
deletePipeline.mockReset();
fetchSources.mockReset();
fetchTriggers.mockReset();
triggerPipeline.mockReset();
fetchRun.mockReset();
// The composer loads the trigger registry on open; default to none.
fetchTriggers.mockResolvedValue([]);
});
it("surfaces the inline error message when a delete fails", async () => {
fetchPipelines.mockResolvedValue(RESPONSE);
deletePipeline.mockRejectedValue(
new HttpError(500, "Server Error", {
detail: "Could not delete pipeline",
}),
);
renderView();
fireEvent.click(await screen.findByText("Redaction sweep"));
fireEvent.click(await screen.findByText("pipelines.detail.delete"));
fireEvent.click(await screen.findByText("pipelines.delete.confirm"));
await waitFor(() => {
expect(deletePipeline).toHaveBeenCalledWith("plc-redaction");
});
expect(
await screen.findByText("Could not delete pipeline"),
).toBeInTheDocument();
});
it("pauses a pipeline by re-saving it with enabled flipped off", async () => {
fetchPipelines.mockResolvedValue(RESPONSE);
fetchPipeline.mockResolvedValue(RAW_REDACTION);
savePipeline.mockResolvedValue({});
renderView();
fireEvent.click(await screen.findByText("Redaction sweep"));
fireEvent.click(await screen.findByText("pipelines.detail.pause"));
await waitFor(() => {
expect(savePipeline).toHaveBeenCalledTimes(1);
});
expect(fetchPipeline).toHaveBeenCalledWith("plc-redaction");
expect(savePipeline).toHaveBeenCalledWith(
expect.objectContaining({ id: "plc-redaction", enabled: false }),
);
});
it("runs a pipeline now and reports success inline", async () => {
fetchPipelines.mockResolvedValue(RESPONSE);
triggerPipeline.mockResolvedValue(["run-1"]);
fetchRun.mockResolvedValue({
runId: "run-1",
policyId: "plc-redaction",
status: "COMPLETED",
currentStep: 1,
stepCount: 1,
error: null,
errorCode: null,
createdAt: 0,
});
renderView();
fireEvent.click(await screen.findByText("Redaction sweep"));
fireEvent.click(await screen.findByText("pipelines.detail.run"));
await waitFor(() => {
expect(triggerPipeline).toHaveBeenCalledWith("plc-redaction");
});
expect(
await screen.findByText("pipelines.run.completed"),
).toBeInTheDocument();
});
it("surfaces an execution failure from a manual run", async () => {
fetchPipelines.mockResolvedValue(RESPONSE);
triggerPipeline.mockResolvedValue(["run-1"]);
fetchRun.mockResolvedValue({
runId: "run-1",
policyId: "plc-redaction",
status: "FAILED",
currentStep: 1,
stepCount: 1,
error: "step 1 blew up",
errorCode: null,
createdAt: 0,
});
renderView();
fireEvent.click(await screen.findByText("Redaction sweep"));
fireEvent.click(await screen.findByText("pipelines.detail.run"));
expect(await screen.findByText("pipelines.run.failed")).toBeInTheDocument();
});
it("creates a pipeline with the chosen name and chained operation", async () => {
fetchPipelines.mockResolvedValue(RESPONSE);
fetchSources.mockResolvedValue(SOURCES);
savePipeline.mockResolvedValue({});
renderView();
// Wait for the table so the initial fetch has settled, then open the composer.
await screen.findByText("Redaction sweep");
fireEvent.click(screen.getByText("pipelines.actions.newPipeline"));
fireEvent.change(await screen.findByRole("textbox"), {
target: { value: "Nightly compress" },
});
// Operation palette chip labels are derived from the endpoint path.
fireEvent.click(await screen.findByText("+ Compress"));
fireEvent.click(screen.getByText("pipelines.composer.create"));
await waitFor(() => {
expect(savePipeline).toHaveBeenCalledTimes(1);
});
expect(savePipeline).toHaveBeenCalledWith(
expect.objectContaining({
name: "Nightly compress",
trigger: null,
output: expect.objectContaining({ type: "inline" }),
steps: [
expect.objectContaining({ operation: "/api/v1/misc/compress-pdf" }),
],
}),
);
});
});
+153 -110
View File
@@ -1,162 +1,205 @@
import { useMemo, useState } from "react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Banner,
Button,
Drawer,
EmptyState,
StatusBadge,
Modal,
Skeleton,
} from "@shared/components";
import { useTier } from "@portal/contexts/TierContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import { errorMessage } from "@portal/api/http";
import {
deletePipeline,
fetchPipeline,
fetchPipelines,
type Pipeline,
type PipelinesResponse,
savePipeline,
type PipelinesOverviewResponse,
type PipelineView,
type Policy,
} from "@portal/api/pipelines";
import { DeployedPipelinesTable } from "@portal/components/pipelines/DeployedPipelinesTable";
import { PipelineCard } from "@portal/components/pipelines/PipelineCard";
import { KpiStrip } from "@portal/components/pipelines/KpiStrip";
import { PipelinesTable } from "@portal/components/pipelines/PipelinesTable";
import { PipelineDetailCard } from "@portal/components/pipelines/PipelineDetailCard";
import { PipelineComposer } from "@portal/components/pipelines/PipelineComposer";
import { PipelineDetail } from "@portal/components/pipelines/PipelineDetail";
import { PipelineListSkeleton } from "@portal/components/pipelines/PipelineListSkeleton";
import { PromotedPipelines } from "@portal/components/pipelines/PromotedPipelines";
import "@portal/views/Pipelines.css";
export function Pipelines() {
const { t } = useTranslation();
const { tier } = useTier();
const state = useAsync<PipelinesResponse>(() => fetchPipelines(tier), [tier]);
const { data } = state;
const { isLoading } = useSectionFlags(state);
// Refetch after every mutation by bumping this counter, so the table reflects
// the backend (mirrors the Sources view).
const [version, setVersion] = useState(0);
const state = useAsync<PipelinesOverviewResponse>(
() => fetchPipelines(),
[version],
);
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 [selected, setSelected] = useState<Pipeline | null>(null);
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 evals = data?.evals ?? null;
const promoted = data?.promoted ?? [];
const isEmpty = !isLoading && pipelines.length === 0;
const expanded = pipelines.find((p) => p.id === expandedId) ?? null;
const fleetHealthy = useMemo(
() => pipelines.filter((p) => p.status === "healthy").length,
[pipelines],
);
function openCreate() {
setEditing(null);
setComposerOpen(true);
}
// Editing needs the raw policy (steps, trigger, source ids), which the overview
// rows don't carry, so fetch it before opening the composer prefilled.
async function openEdit(pipeline: PipelineView) {
if (mutating) return;
setPageError(null);
setMutating(true);
try {
setEditing(await fetchPipeline(pipeline.id));
setComposerOpen(true);
} catch (e) {
setPageError(errorMessage(e));
} finally {
setMutating(false);
}
}
// Pause/resume: re-save the policy with enabled flipped (the backend has no
// dedicated endpoint; every mutation routes through POST /policies). Fetch the
// raw record first so the full config round-trips intact.
async function togglePause(pipeline: PipelineView) {
if (mutating) return;
setPageError(null);
setMutating(true);
try {
const raw = await fetchPipeline(pipeline.id);
await savePipeline({ ...raw, enabled: !raw.enabled });
refetch();
} catch (e) {
setPageError(errorMessage(e));
} finally {
setMutating(false);
}
}
function requestDelete(pipeline: PipelineView) {
setDeleteError(null);
setPendingDelete(pipeline);
}
async function confirmDelete() {
if (!pendingDelete || deleting) return;
setDeleting(true);
setDeleteError(null);
try {
await deletePipeline(pendingDelete.id);
setPendingDelete(null);
setExpandedId(null);
refetch();
} catch (e) {
setDeleteError(errorMessage(e));
} finally {
setDeleting(false);
}
}
return (
<div className="portal-pipelines">
<header className="portal-pipelines__header">
<header className="portal-pipelines__head">
<div>
<h1 className="portal-pipelines__title">{t("pipelines.title")}</h1>
<p className="portal-pipelines__sub">{t("pipelines.subtitle")}</p>
</div>
<Button
variant="gradient"
onClick={() => setComposerOpen(true)}
leadingIcon={<span aria-hidden>+</span>}
>
{t("pipelines.newPipeline")}
<Button onClick={openCreate} leadingIcon={<span aria-hidden>+</span>}>
{t("pipelines.actions.newPipeline")}
</Button>
</header>
{!isLoading && pipelines.length > 0 && (
<div className="portal-pipelines__fleet">
<StatusBadge tone="success" size="sm">
{t("pipelines.fleet.healthy", { count: fleetHealthy })}
</StatusBadge>
{fleetHealthy < pipelines.length && (
<StatusBadge tone="warning" size="sm">
{t("pipelines.fleet.degraded", {
count: pipelines.length - fleetHealthy,
})}
</StatusBadge>
)}
<span className="portal-pipelines__fleet-count">
{t("pipelines.fleet.deployed", { count: pipelines.length })}
</span>
{pageError && <Banner tone="danger" description={pageError} />}
<KpiStrip data={data} loading={loading} />
{isLoading && (
<div className="portal-pipelines__table-skeleton" aria-hidden>
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} height="3rem" />
))}
</div>
)}
{tier === "enterprise" && evals && (
<Banner tone="info" title={t("pipelines.evals.title")}>
{t("pipelines.evals.body", {
count: evals.shadowCount,
comparativeCount: evals.comparativeCount,
detail: evals.detail,
})}
</Banner>
)}
{isLoading && <PipelineListSkeleton />}
{isEmpty && (
<EmptyState
title={t("pipelines.empty.title")}
description={t("pipelines.empty.description")}
actions={
<Button variant="gradient" onClick={() => setComposerOpen(true)}>
{t("pipelines.empty.action")}
</Button>
<Button onClick={openCreate}>{t("pipelines.empty.action")}</Button>
}
/>
)}
{pipelines.length > 0 && (
<section className="portal-pipelines__section">
<h2 className="portal-pipelines__section-h">
{t("pipelines.reliability.heading")}
</h2>
<p className="portal-pipelines__section-sub">
{t("pipelines.reliability.description")}
</p>
<DeployedPipelinesTable
{!isLoading && !isEmpty && pipelines.length > 0 && (
<PipelinesTable
pipelines={pipelines}
onRowClick={setSelected}
/>
</section>
)}
{pipelines.length > 0 && (
<div className="portal-pipelines__list">
{pipelines.map((p) => (
<PipelineCard key={p.id} pipeline={p} onOpen={setSelected} />
))}
</div>
)}
{promoted.length > 0 && (
<section className="portal-pipelines__section">
<h2 className="portal-pipelines__section-h">
{t("pipelines.promoted.heading")}
</h2>
<p className="portal-pipelines__section-sub">
{t("pipelines.promoted.description")}
</p>
<PromotedPipelines promoted={promoted} />
</section>
)}
<Drawer
open={selected !== null}
onClose={() => setSelected(null)}
width="lg"
title={selected?.name}
subtitle={
selected
? t("pipelines.detail.subtitle", {
version: selected.version,
source: selected.source,
destination: selected.destination,
})
: undefined
expandedId={expandedId}
onRowClick={(p) =>
setExpandedId((cur) => (cur === p.id ? null : p.id))
}
>
{selected && <PipelineDetail pipeline={selected} />}
</Drawer>
/>
)}
{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("pipelines.delete.title")}
footer={
<div className="portal-pipelines__composer-footer">
<Button
variant="ghost"
size="sm"
disabled={deleting}
onClick={() => setPendingDelete(null)}
>
{t("pipelines.delete.cancel")}
</Button>
<Button
size="sm"
accent="red"
loading={deleting}
onClick={confirmDelete}
>
{t("pipelines.delete.confirm")}
</Button>
</div>
}
>
<p>{t("pipelines.delete.body", { name: pendingDelete?.name ?? "" })}</p>
{deleteError && <Banner tone="danger" description={deleteError} />}
</Modal>
</div>
);
}