diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index 8a06582b58..64e6aa0523 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -2,6 +2,7 @@ package stirling.software.proprietary.policy.controller; import java.io.IOException; import java.util.ArrayList; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -52,11 +53,15 @@ import stirling.software.proprietary.policy.model.PolicyInputs; import stirling.software.proprietary.policy.model.PolicyRun; import stirling.software.proprietary.policy.model.PolicyRunStatus; import stirling.software.proprietary.policy.model.PolicyRunView; +import stirling.software.proprietary.policy.overview.PoliciesOverviewResponse; +import stirling.software.proprietary.policy.overview.PolicyOverviewService; import stirling.software.proprietary.policy.progress.PolicyProgressListener; import stirling.software.proprietary.policy.source.SourceAccessGuard; import stirling.software.proprietary.policy.source.SourceStore; import stirling.software.proprietary.policy.store.PolicyStore; +import stirling.software.proprietary.policy.trigger.PolicyTrigger; import stirling.software.proprietary.policy.trigger.PolicyTriggerManager; +import stirling.software.proprietary.policy.trigger.TriggerInfo; /** * Policy CRUD plus pipeline runs (stored or ad-hoc). Runs are async: returns a run id, poll {@code @@ -80,6 +85,8 @@ public class PolicyController { private final PolicyAccessGuard policyAccessGuard; private final PolicyManagementAuthority policyManagementAuthority; private final PolicyTriggerManager policyTriggerManager; + private final PolicyOverviewService policyOverviewService; + private final List policyTriggers; private final ApplicationProperties applicationProperties; private final TempFileManager tempFileManager; private final JobOwnershipService jobOwnershipService; @@ -287,6 +294,31 @@ public class PolicyController { return policyAccessGuard.visibleFrom(policyStore); } + @GetMapping("/overview") + @Operation( + summary = "Pipelines overview", + description = + "Returns the KPI strip plus one row per policy the caller's team owns, each with" + + " its referenced sources resolved to names, its pipeline steps, and a" + + " trigger/output summary. Backs the portal's all-pipelines surface.") + public PoliciesOverviewResponse overview() { + return policyOverviewService.overview(); + } + + @GetMapping("/triggers") + @Operation( + summary = "List available triggers", + description = + "Lists each trigger kind with whether it needs a source and which source types" + + " it supports, so the UI can offer triggers and pair them with the" + + " right sources.") + public List triggers() { + return policyTriggers.stream() + .map(TriggerInfo::of) + .sorted(Comparator.comparing(TriggerInfo::type)) + .toList(); + } + @GetMapping("/{policyId}") @Operation(summary = "Get a policy by id") public ResponseEntity getPolicy(@PathVariable String policyId) { @@ -337,6 +369,26 @@ public class PolicyController { return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null)); } + @PostMapping("/{policyId}/trigger") + @Operation( + summary = "Run a stored policy against its sources", + description = + "Pulls the policy's configured sources and runs the pipeline now, regardless of" + + " the enabled flag (which only gates automatic triggering). Returns" + + " the ids of the runs started; poll the run-status endpoint for each." + + " Empty when the sources yielded no work to do.") + public ResponseEntity> trigger(@PathVariable String policyId) { + Policy policy = + policyStore + .get(policyId) + .filter(policyAccessGuard::canAccess) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, "No policy: " + policyId)); + return ResponseEntity.accepted().body(policyRunner.run(policy)); + } + private static void requireRunnable(PipelineDefinition definition) { if (definition.steps().isEmpty()) { throw new ResponseStatusException( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java index 27f43c74da..819b6cae02 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java @@ -1,6 +1,7 @@ package stirling.software.proprietary.policy.engine; import java.io.IOException; +import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; @@ -41,14 +42,15 @@ public class PolicyRunner { * Trigger entry point. Pulls every referenced source; each yielded unit becomes its own run so * one failure does not affect the others. No sources means one run with no input (generator * pipeline). Missing or disabled sources are skipped so one broken reference does not stop the - * rest. + * rest. Returns the ids of the runs it started (empty when sources yielded no work), so a + * manual trigger can report back which runs to follow. */ - public void run(Policy policy) { + public List run(Policy policy) { List sourceIds = policy.sourceIds(); if (sourceIds.isEmpty()) { - startRun(policy, PolicyInputs.of(List.of()), unused -> {}); - return; + return List.of(startRun(policy, PolicyInputs.of(List.of()), unused -> {})); } + List runIds = new ArrayList<>(); for (String sourceId : sourceIds) { Source source = sourceStore.get(sourceId).orElse(null); if (source == null) { @@ -63,8 +65,9 @@ public class PolicyRunner { policy.id()); continue; } - pullAndRun(policy, source.toInputSpec()); + runIds.addAll(pullAndRun(policy, source.toInputSpec())); } + return runIds; } /** Run a stored policy on caller-supplied files (e.g. manual upload), bypassing its sources. */ @@ -79,14 +82,14 @@ public class PolicyRunner { return policyEngine.submit(definition, inputs, listener); } - private void pullAndRun(Policy policy, InputSpec spec) { + private List pullAndRun(Policy policy, InputSpec spec) { InputSource source = sourceFor(spec); if (source == null) { log.warn( "No input source for type '{}' (policy {}); skipping", spec.type(), policy.id()); - return; + return List.of(); } List work; try { @@ -97,19 +100,22 @@ public class PolicyRunner { spec.type(), policy.id(), e.getMessage()); - return; + return List.of(); } + List runIds = new ArrayList<>(); for (ResolvedInput unit : work) { - startRun(policy, unit.inputs(), unit.onComplete()); + runIds.add(startRun(policy, unit.inputs(), unit.onComplete())); } + return runIds; } - private void startRun(Policy policy, PolicyInputs inputs, Consumer onComplete) { + private String startRun(Policy policy, PolicyInputs inputs, Consumer onComplete) { log.info("Running policy {} ({})", policy.id(), policy.name()); PolicyRunHandle handle = policyEngine.runPolicy(policy, inputs, PolicyProgressListener.NOOP); handle.completion() .whenComplete((run, throwable) -> onComplete.accept(succeeded(run, throwable))); + return handle.runId(); } private static boolean succeeded(PolicyRun run, Throwable throwable) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PoliciesOverviewResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PoliciesOverviewResponse.java new file mode 100644 index 0000000000..9f94391f8b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PoliciesOverviewResponse.java @@ -0,0 +1,6 @@ +package stirling.software.proprietary.policy.overview; + +import java.util.List; + +/** The Pipelines overview payload: a KPI strip plus one row per policy. */ +public record PoliciesOverviewResponse(List kpis, List pipelines) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyKpi.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyKpi.java new file mode 100644 index 0000000000..a266cbe883 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyKpi.java @@ -0,0 +1,4 @@ +package stirling.software.proprietary.policy.overview; + +/** One headline figure in the Pipelines overview strip. */ +public record PolicyKpi(long value, String description) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java new file mode 100644 index 0000000000..5ba7856606 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java @@ -0,0 +1,102 @@ +package stirling.software.proprietary.policy.overview; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.policy.config.PolicyAccessGuard; +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineStep; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.TriggerConfig; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceAccessGuard; +import stirling.software.proprietary.policy.source.SourceStore; +import stirling.software.proprietary.policy.store.PolicyStore; + +/** + * Builds the Pipelines overview: every policy the caller's team owns, each annotated with its + * referenced sources (resolved to display names), its pipeline steps, and a trigger/output summary. + * Source names are resolved from the team's sources in memory rather than persisted on the policy, + * so the view always reflects the live source set. This is the "all pipelines" admin surface; the + * user-facing Policies page builds only a friendly subset of the same backend policies. + */ +@Service +@RequiredArgsConstructor +@ConditionalOnBooleanProperty(name = "policies.enabled") +public class PolicyOverviewService { + + private final PolicyStore policyStore; + private final SourceStore sourceStore; + private final PolicyAccessGuard policyAccessGuard; + private final SourceAccessGuard sourceAccessGuard; + + public PoliciesOverviewResponse overview() { + List policies = policyAccessGuard.visibleFrom(policyStore); + Map sourceNames = sourceNames(); + + List views = + policies.stream() + .map(policy -> toView(policy, sourceNames)) + .sorted( + Comparator.comparing( + PolicyView::name, String.CASE_INSENSITIVE_ORDER)) + .toList(); + + return new PoliciesOverviewResponse(buildKpis(policies), views); + } + + /** Display names for every source the caller's team can see, keyed by source id. */ + private Map sourceNames() { + Map names = new HashMap<>(); + for (Source source : sourceAccessGuard.visibleFrom(sourceStore)) { + names.put(source.id(), source.name()); + } + return names; + } + + private static PolicyView toView(Policy policy, Map sourceNames) { + List sources = + policy.sourceIds().stream() + // An unresolved id (source deleted, or not visible) falls back to the id so + // the row still renders rather than dropping the reference silently. + .map(id -> new PolicyView.SourceRef(id, sourceNames.getOrDefault(id, id))) + .toList(); + List steps = policy.steps().stream().map(PipelineStep::operation).toList(); + return new PolicyView( + policy.id(), + policy.name(), + policy.enabled(), + policy.enabled() ? "active" : "paused", + triggerSummary(policy.trigger()), + sources, + steps, + outputSummary(policy.output()), + policy.owner()); + } + + /** A null trigger is a manual-only policy; otherwise the trigger's type keys the summary. */ + private static String triggerSummary(TriggerConfig trigger) { + return trigger == null ? "manual" : trigger.type(); + } + + private static String outputSummary(OutputSpec output) { + return output == null ? "inline" : output.type(); + } + + private static List buildKpis(List policies) { + long total = policies.size(); + long active = policies.stream().filter(Policy::enabled).count(); + long paused = total - active; + return List.of( + new PolicyKpi(total, "pipelines"), + new PolicyKpi(active, "running automatically"), + new PolicyKpi(paused, "paused")); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyView.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyView.java new file mode 100644 index 0000000000..509379a66a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyView.java @@ -0,0 +1,24 @@ +package stirling.software.proprietary.policy.overview; + +import java.util.List; + +/** + * One row in the Pipelines overview: a stored policy shown for the admin portal, with its + * referenced sources resolved to names and its pipeline summarised. The portal's "all pipelines" + * surface lists every backend policy (the user-facing Policies page builds only a friendly subset + * of these). + */ +public record PolicyView( + String id, + String name, + boolean enabled, + String status, + String trigger, + List sources, + List steps, + String output, + String owner) { + + /** A source a policy pulls documents from, resolved to its display name. */ + public record SourceRef(String id, String name) {} +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java index 35c530fad0..79ba3e52e1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java @@ -27,6 +27,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.policy.config.FolderAccessGuard; import stirling.software.proprietary.policy.engine.PolicyRunner; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.model.InputSpec; @@ -74,6 +75,16 @@ public class FolderWatchTrigger implements PolicyTrigger { return TYPE; } + @Override + public boolean requiresSource() { + return true; + } + + @Override + public Set supportedSourceTypes() { + return Set.of(FolderAccessGuard.FOLDER_TYPE); + } + @Override public void validate(Policy policy) { if (watchDirsOf(policy).isEmpty()) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java index a97a9ac880..ade5357162 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java @@ -1,5 +1,7 @@ package stirling.software.proprietary.policy.trigger; +import java.util.Set; + import stirling.software.proprietary.policy.model.Policy; /** @@ -11,6 +13,24 @@ public interface PolicyTrigger { /** Matches {@code TriggerConfig.type()}. */ String type(); + /** + * Whether this trigger needs at least one compatible input source to function. A schedule fires + * on the clock regardless of sources, so it is false; folder-watch derives the directories it + * watches from the policy's sources, so it is true. Drives whether the UI offers the trigger. + */ + default boolean requiresSource() { + return false; + } + + /** + * The source {@code type()}s this trigger is compatible with (e.g. {@code "folder"}). Empty + * means source-agnostic (no constraint). Lets the UI offer a trigger only when a compatible + * source is selected, without hard-coding the relationship. + */ + default Set supportedSourceTypes() { + return Set.of(); + } + /** * Validate at save time so misconfiguration fails fast, not at fire time. Receives the whole * {@link Policy} so triggers that depend on the policy's sources (folder-watch) can check that. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/TriggerInfo.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/TriggerInfo.java new file mode 100644 index 0000000000..f8ad6e278b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/TriggerInfo.java @@ -0,0 +1,18 @@ +package stirling.software.proprietary.policy.trigger; + +import java.util.List; + +/** + * Describes an available trigger for the admin UI: its {@code type} (matching {@code + * TriggerConfig.type()}), whether it needs a compatible source, and which source types it works + * with. Lets the UI list supported triggers and pair them with sources without hard-coding the set. + */ +public record TriggerInfo(String type, boolean requiresSource, List supportedSourceTypes) { + + public static TriggerInfo of(PolicyTrigger trigger) { + return new TriggerInfo( + trigger.type(), + trigger.requiresSource(), + List.copyOf(trigger.supportedSourceTypes())); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java index 98ed101246..8a9a085653 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java @@ -57,12 +57,23 @@ class PolicyControllerTest { @Mock private PolicyAccessGuard policyAccessGuard; @Mock private PolicyManagementAuthority policyManagementAuthority; @Mock private PolicyTriggerManager policyTriggerManager; + + @Mock + private stirling.software.proprietary.policy.overview.PolicyOverviewService + policyOverviewService; + @Mock private TempFileManager tempFileManager; @Mock private JobOwnershipService jobOwnershipService; private ApplicationProperties applicationProperties; private PolicyController controller; + private final java.util.List + policyTriggers = + java.util.List.of( + trigger("schedule", false, java.util.Set.of()), + trigger("folder-watch", true, java.util.Set.of("folder"))); + @BeforeEach void setUp() { applicationProperties = new ApplicationProperties(); @@ -77,11 +88,33 @@ class PolicyControllerTest { policyAccessGuard, policyManagementAuthority, policyTriggerManager, + policyOverviewService, + policyTriggers, applicationProperties, tempFileManager, jobOwnershipService); } + private static stirling.software.proprietary.policy.trigger.PolicyTrigger trigger( + String type, boolean requiresSource, java.util.Set sourceTypes) { + return new stirling.software.proprietary.policy.trigger.PolicyTrigger() { + @Override + public String type() { + return type; + } + + @Override + public boolean requiresSource() { + return requiresSource; + } + + @Override + public java.util.Set supportedSourceTypes() { + return sourceTypes; + } + }; + } + private static PipelineDefinition definitionWithStep() { return new PipelineDefinition( "pipe", List.of(new PipelineStep("/api/v1/misc/compress-pdf", null)), null); @@ -431,4 +464,50 @@ class PolicyControllerTest { .isEqualTo(HttpStatus.NOT_FOUND)); } } + + @Nested + @DisplayName("triggers / trigger") + class Triggers { + + @Test + @DisplayName("lists triggers sorted, with source compatibility") + void listsTriggers() { + List infos = + controller.triggers(); + + assertThat(infos).extracting(t -> t.type()).containsExactly("folder-watch", "schedule"); + stirling.software.proprietary.policy.trigger.TriggerInfo folderWatch = infos.get(0); + assertThat(folderWatch.requiresSource()).isTrue(); + assertThat(folderWatch.supportedSourceTypes()).containsExactly("folder"); + assertThat(infos.get(1).requiresSource()).isFalse(); + assertThat(infos.get(1).supportedSourceTypes()).isEmpty(); + } + + @Test + @DisplayName("trigger runs an accessible policy against its sources and returns run ids") + void triggersRun() { + Policy p = policy("a", 1L); + when(policyStore.get("a")).thenReturn(Optional.of(p)); + when(policyAccessGuard.canAccess(p)).thenReturn(true); + when(policyRunner.run(p)).thenReturn(List.of("run-a", "run-b")); + + ResponseEntity> response = controller.trigger("a"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + assertThat(response.getBody()).containsExactly("run-a", "run-b"); + } + + @Test + @DisplayName("trigger is 404 when the policy is inaccessible") + void triggerNotFound() { + when(policyStore.get("z")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> controller.trigger("z")) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.NOT_FOUND)); + } + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java new file mode 100644 index 0000000000..221cbc4482 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java @@ -0,0 +1,186 @@ +package stirling.software.proprietary.policy.overview; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.UserServiceInterface; +import stirling.software.proprietary.policy.config.PolicyAccessGuard; +import stirling.software.proprietary.policy.config.PolicyManagementAuthority; +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineStep; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.TriggerConfig; +import stirling.software.proprietary.policy.source.InProcessSourceStore; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceAccessGuard; +import stirling.software.proprietary.policy.source.SourceStore; +import stirling.software.proprietary.policy.store.InProcessPolicyStore; +import stirling.software.proprietary.policy.store.PolicyStore; + +/** + * Tests for {@link PolicyOverviewService}: every policy appears once with its sources resolved to + * names, its steps and trigger/output summarised, and the KPI strip counting active vs paused. + * Login is disabled so the team guards pass everything through. + */ +class PolicyOverviewServiceTest { + + private final SourceStore sourceStore = new InProcessSourceStore(); + private final PolicyStore policyStore = new InProcessPolicyStore(); + private PolicyOverviewService service; + + @BeforeEach + void setUp() { + ApplicationProperties properties = new ApplicationProperties(); + properties.getSecurity().setEnableLogin(false); + UserServiceInterface userService = mock(UserServiceInterface.class); + PolicyManagementAuthority authority = mock(PolicyManagementAuthority.class); + SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority); + PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority); + service = new PolicyOverviewService(policyStore, sourceStore, policyGuard, sourceGuard); + } + + @Test + void eachPolicyAppearsWithResolvedSourcesStepsAndSummary() { + Source claims = source("Claims intake", "/claims"); + policyStore.save( + new Policy( + null, + "Redaction", + "owner", + true, + new TriggerConfig("schedule", Map.of()), + List.of(claims.id()), + List.of(new PipelineStep("/api/v1/security/auto-redact", Map.of())), + OutputSpec.inline())); + policyStore.save( + new Policy( + null, + "Archive (paused)", + "owner", + false, + null, + List.of(), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.inline())); + + PoliciesOverviewResponse response = service.overview(); + + assertEquals(2, response.pipelines().size()); + // Sorted by name, case-insensitive, so "Archive" leads "Redaction". + PolicyView archive = response.pipelines().get(0); + assertEquals("Archive (paused)", archive.name()); + assertEquals("paused", archive.status()); + assertEquals("manual", archive.trigger()); + + PolicyView redaction = find(response, "Redaction"); + assertEquals("active", redaction.status()); + assertEquals("schedule", redaction.trigger()); + assertEquals("inline", redaction.output()); + assertEquals(List.of("/api/v1/security/auto-redact"), redaction.steps()); + assertEquals(1, redaction.sources().size()); + assertEquals(claims.id(), redaction.sources().get(0).id()); + assertEquals("Claims intake", redaction.sources().get(0).name()); + + // KPI strip: total, active, paused. + assertEquals(List.of(2L, 1L, 1L), response.kpis().stream().map(PolicyKpi::value).toList()); + } + + @Test + void anUnresolvedSourceFallsBackToItsId() { + policyStore.save( + new Policy( + null, + "Orphan", + "owner", + true, + null, + List.of("src-missing"), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.inline())); + + PolicyView view = find(service.overview(), "Orphan"); + assertEquals(1, view.sources().size()); + assertEquals("src-missing", view.sources().get(0).id()); + assertEquals("src-missing", view.sources().get(0).name()); + } + + @Test + void overviewLoadsOnlyTheCallersTeam() { + ApplicationProperties properties = new ApplicationProperties(); + properties.getSecurity().setEnableLogin(true); + UserServiceInterface userService = mock(UserServiceInterface.class); + PolicyManagementAuthority authority = mock(PolicyManagementAuthority.class); + when(authority.currentUserTeamId()).thenReturn(1L); + SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority); + PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority); + PolicyOverviewService scoped = + new PolicyOverviewService(policyStore, sourceStore, policyGuard, sourceGuard); + + Source ours = teamSource("Ours", "/ours", 1L); + teamPolicy("Our policy", 1L, ours.id()); + teamPolicy("Their policy", 2L, ours.id()); + + PoliciesOverviewResponse response = scoped.overview(); + + assertEquals(1, response.pipelines().size()); + PolicyView view = response.pipelines().get(0); + assertEquals("Our policy", view.name()); + assertEquals("Ours", view.sources().get(0).name()); + assertEquals(List.of(1L, 1L, 0L), response.kpis().stream().map(PolicyKpi::value).toList()); + } + + @Test + void emptyStoreReportsZeroKpis() { + PoliciesOverviewResponse response = service.overview(); + assertTrue(response.pipelines().isEmpty()); + assertEquals(List.of(0L, 0L, 0L), response.kpis().stream().map(PolicyKpi::value).toList()); + } + + private Source source(String name, String directory) { + return sourceStore.save( + new Source( + null, name, "folder", Map.of("directory", directory), true, "owner", null)); + } + + private Source teamSource(String name, String directory, Long teamId) { + return sourceStore.save( + new Source( + null, + name, + "folder", + Map.of("directory", directory), + true, + "owner", + teamId)); + } + + private void teamPolicy(String name, Long teamId, String... sourceIds) { + policyStore.save( + new Policy( + null, + name, + "owner", + true, + null, + List.of(sourceIds), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.inline(), + teamId)); + } + + private static PolicyView find(PoliciesOverviewResponse response, String name) { + return response.pipelines().stream() + .filter(view -> view.name().equals(name)) + .findFirst() + .orElseThrow(); + } +} diff --git a/frontend/portal/public/locales/en-US/translation.toml b/frontend/portal/public/locales/en-US/translation.toml index c0b3ccae91..4e90ab71f1 100644 --- a/frontend/portal/public/locales/en-US/translation.toml +++ b/frontend/portal/public/locales/en-US/translation.toml @@ -206,141 +206,96 @@ description = "The component catalogue could not be loaded. Try again shortly." [pipelines] title = "Pipelines" -subtitle = "Document workflows composed from typed operations — deployed, versioned, and continuously validated against a golden set." +subtitle = "Every automated document pipeline on the backend: an ordered chain of operations over a set of sources, run on a trigger. Click a row for its steps and sources." + +[pipelines.actions] newPipeline = "New pipeline" +[pipelines.kpi] +total = "Pipelines" +active = "Active" +paused = "Paused" + [pipelines.status] -healthy = "Healthy" -degraded = "Degraded" +active = "Active" +paused = "Paused" -[pipelines.fleet] -healthy_one = "{{count}} healthy" -healthy_other = "{{count}} healthy" -degraded_one = "{{count}} degraded" -degraded_other = "{{count}} degraded" -deployed_one = "{{count}} deployed" -deployed_other = "{{count}} deployed" +[pipelines.trigger] +manual = "Manual" +schedule = "Scheduled" +folder-watch = "Folder watch" -[pipelines.evals] -title = "Shadow + comparative evals active" -body_one = "{{count}} pipeline running a shadow eval, {{comparativeCount}} in a comparative run. {{detail}}" -body_other = "{{count}} pipelines running a shadow eval, {{comparativeCount}} in a comparative run. {{detail}}" +[pipelines.output] +inline = "Return files" +folder = "Write to folder" [pipelines.empty] title = "No pipelines yet" -description = "Compose your first document workflow from the typed operation library — pick a source, chain the ops, and route the output." -action = "Build your first pipeline" - -[pipelines.reliability] -heading = "Golden-set reliability" -description = "Pass rate against each pipeline's golden set, judged against its own bound. Anything below bound shows amber or red." - -[pipelines.promoted] -heading = "Promoted from the Editor" -description = "Watch-folder flows built in the Editor and promoted into the portal. Promote one to a policy to apply its rules fleet-wide." -policyCreated = "Policy created" -promoteToPolicy = "Promote to policy" - -[pipelines.promoted.table] -sourceDocType = "Source doc type" -watchFolder = "Watch folder" -status = "Status" - -[pipelines.promoted.status] -deployed = "Deployed" -staged = "Staged" -review = "Needs review" - -[pipelines.table.header] -name = "Pipeline" -health = "Health" -goldenSet = "Golden set" -docs24h = "Docs / 24h" -version = "Version" +description = "Create your first pipeline: pick the sources it runs over, chain the operations, and choose where output goes." +action = "Create a pipeline" [pipelines.table] -boundTooltip = "Bound: {{bound}}" +name = "Pipeline" +status = "Status" +steps = "Steps" +sources = "Sources" -[pipelines.metrics] -docs24h = "Docs / 24h" -throughput = "Throughput" -errorRate = "Error rate" -p95Latency = "P95 latency" -uptime = "Uptime" +[pipelines.detail] +subtitle = "{{trigger}} · {{status}}" +closeAriaLabel = "Close detail" +steps = "Operations" +noSteps = "No operations configured." +sources = "Sources" +noSources = "No sources. Files are supplied directly to each run." +output = "Output" +run = "Run now" +edit = "Edit" +pause = "Pause" +resume = "Resume" +delete = "Delete pipeline" -[pipelines.card] -stageTooltip_one = "{{label}}: {{count}} op" -stageTooltip_other = "{{label}}: {{count}} ops" -golden = "Golden {{passing}}/{{total}}" -drift_one = "{{count}} drift" -drift_other = "{{count}} drifts" +[pipelines.run] +empty = "Nothing to run: the sources had no documents to process." +failed = "Run failed: {{error}}" +running = "Run started; still in progress." +completed_one = "Run completed." +completed_other = "All {{count}} runs completed." + +[pipelines.delete] +title = "Delete pipeline?" +body = "Delete \"{{name}}\"? This can't be undone." +cancel = "Cancel" +confirm = "Delete" [pipelines.composer] title = "New pipeline" -subtitle = "Pick a source, compose the operation chain, then route the output." +editTitle = "Edit pipeline" +subtitle = "Pick the sources it runs over, chain the operations, then choose when it runs and where output goes." cancel = "Cancel" -back = "Back" -deploy = "Deploy pipeline" -continue = "Continue" -quickAddBundles = "Quick-add bundles" -chainEmpty = "Add operations from the library below." -operationChain_one = "Operation chain ({{count}})" -operationChain_other = "Operation chain ({{count}})" -destination = "Destination" -alerts = "Alerts" +create = "Create pipeline" +save = "Save changes" +name = "Name" +namePlaceholder = "e.g. Redaction sweep" +sources = "Sources" +sourcesLoading = "Loading sources..." +noSources = "No sources connected yet. The pipeline can still run on files supplied to it directly." +operations_one = "Operation ({{count}})" +operations_other = "Operations ({{count}})" +chainEmpty = "Add operations from the palette below." +moveUp = "Move up" +moveDown = "Move down" +removeStep = "Remove operation" +trigger = "Trigger" +triggerManual = "Manual only" +scheduleEvery = "Run every" +output = "Output" +directory = "Output folder" +directoryHelp = "Absolute path on the server. Must be within the configured allowed folders." -[pipelines.composer.steps] -source = "Source" -operations = "Operations" -routing = "Routing" - -[pipelines.composer.anySource] -label = "Any source" -desc = "Accept documents from every connected channel" - -[pipelines.composer.opKind] -ingest = "Ingest" -validate = "Validate" -modify = "Modify" -secure = "Secure" -store = "Route / Store" -alert = "Alerts" - -[pipelines.composer.alert.email] -title = "Email on failure" -desc = "Notify the on-call list when error rate trips its bound" - -[pipelines.composer.alert.webhook] -title = "Webhook on completion" -desc = "POST a run summary to a URL you control" - -[pipelines.composer.alert.review] -title = "Route low-confidence to review" -desc = "Send docs under the confidence bound to a human queue" - -[pipelines.detail] -subtitle = "{{version}} · {{source}} → {{destination}}" - -[pipelines.detail.stages] -heading = "Pipeline stages" -description = "Every document flows through five stages between {{source}} and {{destination}}." -noOps = "No ops" - -[pipelines.detail.golden] -heading = "Golden-set validation" -passing = "{{passing}} of {{total}} passing" -lastRun = "last run {{lastRun}}" -barLabel = "Golden set {{passing}} of {{total}} passing" - -[pipelines.detail.drift] -heading = "Schema drift" -confidence = "{{delta}} conf" -docs_one = "{{count}} docs" -docs_other = "{{count}} docs" - -[pipelines.detail.drift.empty] -title = "No drift detected" -description = "Every document in the last 24h matched its inferred schema." +[pipelines.composer.unit] +minutes = "minutes" +hours = "hours" +days = "days" [sources] title = "Sources" diff --git a/frontend/portal/src/api/pipelines.ts b/frontend/portal/src/api/pipelines.ts index e7bb6222b6..20f26fccc6 100644 --- a/frontend/portal/src/api/pipelines.ts +++ b/frontend/portal/src/api/pipelines.ts @@ -1,39 +1,170 @@ import { apiClient } from "@portal/api/http"; -import type { PipelinesResponse } from "@portal/mocks/pipelines"; -import type { Tier } from "@portal/contexts/TierContext"; -export type { - EvalsNote, - GoldenSet, - Pipeline, - PipelineMetrics, - PipelinesResponse, - PipelineStatus, - PromotedPipeline, - PromotedStatus, - SchemaDrift, - StageKey, - StageSummary, -} from "@portal/mocks/pipelines"; +/** + * Pipelines service layer: the backend contract. + * + * A "pipeline" in the portal IS a backend policy (PolicyController, Policy.java): + * an ordered chain of tool steps with input sources, a trigger, and an output + * destination. This surface lists EVERY backend policy (the user-facing Policies + * page builds only a friendly subset of the same records). Like Sources, it calls + * the REAL Stirling API base `/api/v1/policies`, so dropping MSW points these exact + * calls at the live backend. + */ -/** GET /v1/pipelines?tier=… — the deployed fleet plus tier-specific extras. */ -export async function fetchPipelines(tier: Tier): Promise { - return apiClient.local.json( - `/v1/pipelines?tier=${encodeURIComponent(tier)}`, - ); +/** One tool invocation in a pipeline. `operation` is a Stirling endpoint path. */ +export interface PipelineStep { + operation: string; + parameters: Record; + fileParameters?: Record; +} + +/** When a policy fires automatically. `type` keys a trigger bean (e.g. "schedule"). */ +export interface TriggerConfig { + type: string; + options: Record; +} + +/** Where a run's outputs are delivered. `type` keys an output sink (e.g. "inline"). */ +export interface OutputSpec { + type: string; + options: Record; } /** - * Promote a watch-folder-derived pipeline into a governed org policy, so its - * rules apply fleet-wide instead of just to the originating flow. - * - * TODO(backend): POST /v1/pipelines/{id}/promote-to-policy — should create the - * policy from the pipeline's stages and return the new policy id. The mock - * handler resolves `{ ok: true }`; the UI treats a resolved promise as accepted. + * The stored policy record: the create/update body (`id` blank on create) and what + * the backend returns from GET/POST. Mirrors Policy.java exactly; `owner`/`teamId` + * are stamped server-side. A `null` trigger means manual-only. */ -export async function promoteToPolicy(id: string): Promise<{ ok: true }> { - return apiClient.local.json<{ ok: true }>( - `/v1/pipelines/${encodeURIComponent(id)}/promote-to-policy`, +export interface Policy { + id?: string; + name: string; + owner?: string | null; + enabled: boolean; + trigger: TriggerConfig | null; + sourceIds: string[]; + steps: PipelineStep[]; + output: OutputSpec; + teamId?: number | null; +} + +/** Overview row status: enabled (fires automatically) or paused. */ +export type PipelineStatus = "active" | "paused"; + +/** A source a pipeline pulls documents from, resolved to its display name. */ +export interface PipelineSourceRef { + id: string; + name: string; +} + +/** One row in the Pipelines overview. Mirrors the backend `PolicyView`. */ +export interface PipelineView { + id: string; + name: string; + enabled: boolean; + status: PipelineStatus; + /** Trigger summary: "manual" or the trigger type (e.g. "schedule"). */ + trigger: string; + sources: PipelineSourceRef[]; + /** Operation endpoint paths, in run order. */ + steps: string[]; + /** Output sink type (e.g. "inline", "folder"). */ + output: string; + owner: string; +} + +export interface PipelineKpi { + value: number; + description: string; +} + +export interface PipelinesOverviewResponse { + kpis: PipelineKpi[]; + pipelines: PipelineView[]; +} + +/** A trigger kind and the source types it works with. Mirrors the backend `TriggerInfo`. */ +export interface TriggerInfo { + /** Matches `TriggerConfig.type` (e.g. "schedule", "folder-watch"). */ + type: string; + /** Whether the trigger needs at least one compatible source to function. */ + requiresSource: boolean; + /** Source types it supports; empty means source-agnostic (no constraint). */ + supportedSourceTypes: string[]; +} + +export type PolicyRunStatus = + | "PENDING" + | "RUNNING" + | "WAITING_FOR_INPUT" + | "COMPLETED" + | "FAILED" + | "CANCELLED"; + +/** A run's current state. Mirrors the backend `PolicyRunView` (outputs elided). */ +export interface PolicyRunView { + runId: string; + policyId: string | null; + status: PolicyRunStatus; + currentStep: number; + stepCount: number; + /** Human-readable failure message; set when status is FAILED. */ + error: string | null; + errorCode: string | null; + createdAt: number; +} + +/** GET /api/v1/policies/overview: KPI strip + one row per policy for the admin. */ +export async function fetchPipelines(): Promise { + return apiClient.local.json( + "/api/v1/policies/overview", + ); +} + +/** GET /api/v1/policies/{id}: the raw policy record (steps, sources, trigger), for editing. */ +export async function fetchPipeline(id: string): Promise { + return apiClient.local.json( + `/api/v1/policies/${encodeURIComponent(id)}`, + ); +} + +/** POST /api/v1/policies: create (blank id) or update (matched id) a policy. */ +export async function savePipeline(policy: Policy): Promise { + return apiClient.local.json("/api/v1/policies", { + method: "POST", + body: policy, + }); +} + +/** DELETE /api/v1/policies/{id}: remove a policy. */ +export async function deletePipeline(id: string): Promise { + await apiClient.local.json( + `/api/v1/policies/${encodeURIComponent(id)}`, + { + method: "DELETE", + }, + ); +} + +/** GET /api/v1/policies/triggers: available triggers + their source compatibility. */ +export async function fetchTriggers(): Promise { + return apiClient.local.json("/api/v1/policies/triggers"); +} + +/** + * POST /api/v1/policies/{id}/trigger: run the pipeline now against its configured + * sources, regardless of the enabled flag. Returns the ids of the runs started + * (empty when the sources yielded no work); poll {@link fetchRun} for each. + */ +export async function triggerPipeline(id: string): Promise { + return apiClient.local.json( + `/api/v1/policies/${encodeURIComponent(id)}/trigger`, { method: "POST" }, ); } + +/** GET /api/v1/policies/run/{runId}: current status, error, and step cursor of a run. */ +export async function fetchRun(runId: string): Promise { + return apiClient.local.json( + `/api/v1/policies/run/${encodeURIComponent(runId)}`, + ); +} diff --git a/frontend/portal/src/components/pipelines/DeployedPipelinesTable.stories.tsx b/frontend/portal/src/components/pipelines/DeployedPipelinesTable.stories.tsx deleted file mode 100644 index 3dc731a47a..0000000000 --- a/frontend/portal/src/components/pipelines/DeployedPipelinesTable.stories.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { DeployedPipelinesTable } from "@portal/components/pipelines/DeployedPipelinesTable"; -import { - DEGRADED_PIPELINE, - HEALTHY_PIPELINE, -} from "@portal/components/pipelines/storyFixtures"; -import "@portal/views/Pipelines.css"; - -const meta: Meta = { - title: "Portal/Pipelines/DeployedPipelinesTable", - component: DeployedPipelinesTable, - parameters: { layout: "padded" }, - args: { onRowClick: () => {} }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -/** A healthy pipeline at bound and one degraded below its golden-set bound. */ -export const Default: Story = { - args: { pipelines: [HEALTHY_PIPELINE, DEGRADED_PIPELINE] }, -}; - -export const Empty: Story = { - args: { pipelines: [] }, -}; diff --git a/frontend/portal/src/components/pipelines/DeployedPipelinesTable.tsx b/frontend/portal/src/components/pipelines/DeployedPipelinesTable.tsx deleted file mode 100644 index 03d2e6f015..0000000000 --- a/frontend/portal/src/components/pipelines/DeployedPipelinesTable.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { StatusBadge, Table, type TableColumn } from "@shared/components"; -import type { Pipeline } from "@portal/api/pipelines"; -import { compact, goldenTone, pct } from "@portal/components/pipelines/format"; - -interface DeployedPipelinesTableProps { - pipelines: Pipeline[]; - onRowClick: (p: Pipeline) => void; -} - -/** - * Dense roster of the deployed fleet that puts golden-set reliability up front. - * The card list below it carries the full per-pipeline story; this table is the - * scannable "is anything below its bound?" view across the whole fleet. - */ -export function DeployedPipelinesTable({ - pipelines, - onRowClick, -}: DeployedPipelinesTableProps) { - const { t } = useTranslation(); - const columns = useMemo[]>( - () => [ - { - key: "name", - header: t("pipelines.table.header.name"), - render: (p) => ( -
- {p.name} - - {p.source} → {p.destination} - -
- ), - }, - { - key: "status", - header: t("pipelines.table.header.health"), - render: (p) => ( - - {p.status === "degraded" - ? t("pipelines.status.degraded") - : t("pipelines.status.healthy")} - - ), - }, - { - key: "golden", - header: t("pipelines.table.header.goldenSet"), - width: "11rem", - render: (p) => { - const tone = goldenTone(p.golden); - const rate = p.golden.total ? p.golden.passing / p.golden.total : 0; - return ( -
- - {p.golden.passing}/{p.golden.total} - - - {pct(rate, 1)} - -
- ); - }, - }, - { - key: "docs", - header: t("pipelines.table.header.docs24h"), - align: "right", - render: (p) => ( - - {compact(p.metrics.docs24h)} - - ), - }, - { - key: "version", - header: t("pipelines.table.header.version"), - align: "right", - render: (p) => ( - {p.version} - ), - }, - ], - [t], - ); - - return ( - - className="portal-pipelines__roster" - columns={columns} - rows={pipelines} - rowKey={(p) => p.id} - onRowClick={onRowClick} - /> - ); -} diff --git a/frontend/portal/src/components/pipelines/KpiStrip.tsx b/frontend/portal/src/components/pipelines/KpiStrip.tsx new file mode 100644 index 0000000000..6acab35912 --- /dev/null +++ b/frontend/portal/src/components/pipelines/KpiStrip.tsx @@ -0,0 +1,39 @@ +import { useTranslation } from "react-i18next"; +import { MetricCard, MetricStrip } from "@shared/components"; +import type { PipelinesOverviewResponse } from "@portal/api/pipelines"; + +/** + * KPI labels are product copy: they describe what each metric IS, not its value. + * They stay client-side so the strip's structure is stable across loading / empty + * / ready states; only values + descriptions flow from the API. Order matches + * PolicyOverviewService.buildKpis: total, active, paused. + */ +const KPI_LABEL_KEYS = [ + "pipelines.kpi.total", + "pipelines.kpi.active", + "pipelines.kpi.paused", +] as const; + +interface KpiStripProps { + data: PipelinesOverviewResponse | null; + loading: boolean; +} + +export function KpiStrip({ data, loading }: KpiStripProps) { + const { t } = useTranslation(); + return ( + + {KPI_LABEL_KEYS.map((labelKey, i) => { + const k = loading ? undefined : data?.kpis[i]; + return ( + + ); + })} + + ); +} diff --git a/frontend/portal/src/components/pipelines/PipelineCard.stories.tsx b/frontend/portal/src/components/pipelines/PipelineCard.stories.tsx deleted file mode 100644 index 0df2755352..0000000000 --- a/frontend/portal/src/components/pipelines/PipelineCard.stories.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { PipelineCard } from "@portal/components/pipelines/PipelineCard"; -import { - DEGRADED_PIPELINE, - HEALTHY_PIPELINE, -} from "@portal/components/pipelines/storyFixtures"; - -const meta: Meta = { - title: "Portal/Pipelines/PipelineCard", - component: PipelineCard, - parameters: { layout: "padded" }, - args: { onOpen: () => console.log("open") }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -export const Healthy: Story = { - args: { pipeline: HEALTHY_PIPELINE }, -}; - -export const DegradedWithDrift: Story = { - args: { pipeline: DEGRADED_PIPELINE }, -}; diff --git a/frontend/portal/src/components/pipelines/PipelineCard.tsx b/frontend/portal/src/components/pipelines/PipelineCard.tsx deleted file mode 100644 index 596ec8cd86..0000000000 --- a/frontend/portal/src/components/pipelines/PipelineCard.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Card, StatTile, StatusBadge } from "@shared/components"; -import type { Pipeline, StageSummary } from "@portal/api/pipelines"; -import { - STAGE_ACCENT, - STAGE_COLOR_VAR, -} from "@portal/components/pipelines/stageAccent"; -import { compact, pct } from "@portal/components/pipelines/format"; - -/** Compact five-dot stage indicator: a lit dot per stage that has ops. */ -function StageDots({ stages }: { stages: StageSummary[] }) { - const { t } = useTranslation(); - return ( - - {stages.map((s) => ( - - ))} - - ); -} - -export interface PipelineCardProps { - pipeline: Pipeline; - onOpen: (p: Pipeline) => void; -} - -/** Row in the deployed fleet: health, source→stages→destination rail, 24h metrics. */ -export function PipelineCard({ pipeline, onOpen }: PipelineCardProps) { - const { t } = useTranslation(); - const m = pipeline.metrics; - const degraded = pipeline.status === "degraded"; - const errorTone = - m.errorRate >= 0.02 - ? "danger" - : m.errorRate >= 0.01 - ? "warning" - : "default"; - const driftCount = pipeline.drift.length; - - return ( - onOpen(pipeline)} - > -
-
-

{pipeline.name}

-

{pipeline.blurb}

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

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

    -

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

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

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

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

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

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

    {pipeline.name}

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

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

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

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

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

    {t("pipelines.title")}

    {t("pipelines.subtitle")}

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

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

    -

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

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

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

    -

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

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

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

    + {deleteError && } +
    ); }