Merge branch 'main' into feature/documentClassifierAgent
@@ -2,6 +2,7 @@ package stirling.software.proprietary.policy.controller;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -52,11 +53,15 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||
import stirling.software.proprietary.policy.model.PolicyRun;
|
||||
import stirling.software.proprietary.policy.model.PolicyRunStatus;
|
||||
import stirling.software.proprietary.policy.model.PolicyRunView;
|
||||
import stirling.software.proprietary.policy.overview.PoliciesOverviewResponse;
|
||||
import stirling.software.proprietary.policy.overview.PolicyOverviewService;
|
||||
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
||||
import stirling.software.proprietary.policy.source.SourceAccessGuard;
|
||||
import stirling.software.proprietary.policy.source.SourceStore;
|
||||
import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
import stirling.software.proprietary.policy.trigger.PolicyTrigger;
|
||||
import stirling.software.proprietary.policy.trigger.PolicyTriggerManager;
|
||||
import stirling.software.proprietary.policy.trigger.TriggerInfo;
|
||||
|
||||
/**
|
||||
* Policy CRUD plus pipeline runs (stored or ad-hoc). Runs are async: returns a run id, poll {@code
|
||||
@@ -80,6 +85,8 @@ public class PolicyController {
|
||||
private final PolicyAccessGuard policyAccessGuard;
|
||||
private final PolicyManagementAuthority policyManagementAuthority;
|
||||
private final PolicyTriggerManager policyTriggerManager;
|
||||
private final PolicyOverviewService policyOverviewService;
|
||||
private final List<PolicyTrigger> policyTriggers;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final JobOwnershipService jobOwnershipService;
|
||||
@@ -287,6 +294,31 @@ public class PolicyController {
|
||||
return policyAccessGuard.visibleFrom(policyStore);
|
||||
}
|
||||
|
||||
@GetMapping("/overview")
|
||||
@Operation(
|
||||
summary = "Pipelines overview",
|
||||
description =
|
||||
"Returns the KPI strip plus one row per policy the caller's team owns, each with"
|
||||
+ " its referenced sources resolved to names, its pipeline steps, and a"
|
||||
+ " trigger/output summary. Backs the portal's all-pipelines surface.")
|
||||
public PoliciesOverviewResponse overview() {
|
||||
return policyOverviewService.overview();
|
||||
}
|
||||
|
||||
@GetMapping("/triggers")
|
||||
@Operation(
|
||||
summary = "List available triggers",
|
||||
description =
|
||||
"Lists each trigger kind with whether it needs a source and which source types"
|
||||
+ " it supports, so the UI can offer triggers and pair them with the"
|
||||
+ " right sources.")
|
||||
public List<TriggerInfo> triggers() {
|
||||
return policyTriggers.stream()
|
||||
.map(TriggerInfo::of)
|
||||
.sorted(Comparator.comparing(TriggerInfo::type))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GetMapping("/{policyId}")
|
||||
@Operation(summary = "Get a policy by id")
|
||||
public ResponseEntity<Policy> getPolicy(@PathVariable String policyId) {
|
||||
@@ -337,6 +369,26 @@ public class PolicyController {
|
||||
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
|
||||
}
|
||||
|
||||
@PostMapping("/{policyId}/trigger")
|
||||
@Operation(
|
||||
summary = "Run a stored policy against its sources",
|
||||
description =
|
||||
"Pulls the policy's configured sources and runs the pipeline now, regardless of"
|
||||
+ " the enabled flag (which only gates automatic triggering). Returns"
|
||||
+ " the ids of the runs started; poll the run-status endpoint for each."
|
||||
+ " Empty when the sources yielded no work to do.")
|
||||
public ResponseEntity<List<String>> trigger(@PathVariable String policyId) {
|
||||
Policy policy =
|
||||
policyStore
|
||||
.get(policyId)
|
||||
.filter(policyAccessGuard::canAccess)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "No policy: " + policyId));
|
||||
return ResponseEntity.accepted().body(policyRunner.run(policy));
|
||||
}
|
||||
|
||||
private static void requireRunnable(PipelineDefinition definition) {
|
||||
if (definition.steps().isEmpty()) {
|
||||
throw new ResponseStatusException(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package stirling.software.proprietary.policy.engine;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
@@ -41,14 +42,15 @@ public class PolicyRunner {
|
||||
* Trigger entry point. Pulls every referenced source; each yielded unit becomes its own run so
|
||||
* one failure does not affect the others. No sources means one run with no input (generator
|
||||
* pipeline). Missing or disabled sources are skipped so one broken reference does not stop the
|
||||
* rest.
|
||||
* rest. Returns the ids of the runs it started (empty when sources yielded no work), so a
|
||||
* manual trigger can report back which runs to follow.
|
||||
*/
|
||||
public void run(Policy policy) {
|
||||
public List<String> run(Policy policy) {
|
||||
List<String> sourceIds = policy.sourceIds();
|
||||
if (sourceIds.isEmpty()) {
|
||||
startRun(policy, PolicyInputs.of(List.of()), unused -> {});
|
||||
return;
|
||||
return List.of(startRun(policy, PolicyInputs.of(List.of()), unused -> {}));
|
||||
}
|
||||
List<String> runIds = new ArrayList<>();
|
||||
for (String sourceId : sourceIds) {
|
||||
Source source = sourceStore.get(sourceId).orElse(null);
|
||||
if (source == null) {
|
||||
@@ -63,8 +65,9 @@ public class PolicyRunner {
|
||||
policy.id());
|
||||
continue;
|
||||
}
|
||||
pullAndRun(policy, source.toInputSpec());
|
||||
runIds.addAll(pullAndRun(policy, source.toInputSpec()));
|
||||
}
|
||||
return runIds;
|
||||
}
|
||||
|
||||
/** Run a stored policy on caller-supplied files (e.g. manual upload), bypassing its sources. */
|
||||
@@ -79,14 +82,14 @@ public class PolicyRunner {
|
||||
return policyEngine.submit(definition, inputs, listener);
|
||||
}
|
||||
|
||||
private void pullAndRun(Policy policy, InputSpec spec) {
|
||||
private List<String> pullAndRun(Policy policy, InputSpec spec) {
|
||||
InputSource source = sourceFor(spec);
|
||||
if (source == null) {
|
||||
log.warn(
|
||||
"No input source for type '{}' (policy {}); skipping",
|
||||
spec.type(),
|
||||
policy.id());
|
||||
return;
|
||||
return List.of();
|
||||
}
|
||||
List<ResolvedInput> work;
|
||||
try {
|
||||
@@ -97,19 +100,22 @@ public class PolicyRunner {
|
||||
spec.type(),
|
||||
policy.id(),
|
||||
e.getMessage());
|
||||
return;
|
||||
return List.of();
|
||||
}
|
||||
List<String> runIds = new ArrayList<>();
|
||||
for (ResolvedInput unit : work) {
|
||||
startRun(policy, unit.inputs(), unit.onComplete());
|
||||
runIds.add(startRun(policy, unit.inputs(), unit.onComplete()));
|
||||
}
|
||||
return runIds;
|
||||
}
|
||||
|
||||
private void startRun(Policy policy, PolicyInputs inputs, Consumer<Boolean> onComplete) {
|
||||
private String startRun(Policy policy, PolicyInputs inputs, Consumer<Boolean> onComplete) {
|
||||
log.info("Running policy {} ({})", policy.id(), policy.name());
|
||||
PolicyRunHandle handle =
|
||||
policyEngine.runPolicy(policy, inputs, PolicyProgressListener.NOOP);
|
||||
handle.completion()
|
||||
.whenComplete((run, throwable) -> onComplete.accept(succeeded(run, throwable)));
|
||||
return handle.runId();
|
||||
}
|
||||
|
||||
private static boolean succeeded(PolicyRun run, Throwable throwable) {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package stirling.software.proprietary.policy.overview;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** The Pipelines overview payload: a KPI strip plus one row per policy. */
|
||||
public record PoliciesOverviewResponse(List<PolicyKpi> kpis, List<PolicyView> pipelines) {}
|
||||
@@ -0,0 +1,4 @@
|
||||
package stirling.software.proprietary.policy.overview;
|
||||
|
||||
/** One headline figure in the Pipelines overview strip. */
|
||||
public record PolicyKpi(long value, String description) {}
|
||||
@@ -0,0 +1,102 @@
|
||||
package stirling.software.proprietary.policy.overview;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.model.PipelineStep;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
import stirling.software.proprietary.policy.model.TriggerConfig;
|
||||
import stirling.software.proprietary.policy.source.Source;
|
||||
import stirling.software.proprietary.policy.source.SourceAccessGuard;
|
||||
import stirling.software.proprietary.policy.source.SourceStore;
|
||||
import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
|
||||
/**
|
||||
* Builds the Pipelines overview: every policy the caller's team owns, each annotated with its
|
||||
* referenced sources (resolved to display names), its pipeline steps, and a trigger/output summary.
|
||||
* Source names are resolved from the team's sources in memory rather than persisted on the policy,
|
||||
* so the view always reflects the live source set. This is the "all pipelines" admin surface; the
|
||||
* user-facing Policies page builds only a friendly subset of the same backend policies.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class PolicyOverviewService {
|
||||
|
||||
private final PolicyStore policyStore;
|
||||
private final SourceStore sourceStore;
|
||||
private final PolicyAccessGuard policyAccessGuard;
|
||||
private final SourceAccessGuard sourceAccessGuard;
|
||||
|
||||
public PoliciesOverviewResponse overview() {
|
||||
List<Policy> policies = policyAccessGuard.visibleFrom(policyStore);
|
||||
Map<String, String> sourceNames = sourceNames();
|
||||
|
||||
List<PolicyView> views =
|
||||
policies.stream()
|
||||
.map(policy -> toView(policy, sourceNames))
|
||||
.sorted(
|
||||
Comparator.comparing(
|
||||
PolicyView::name, String.CASE_INSENSITIVE_ORDER))
|
||||
.toList();
|
||||
|
||||
return new PoliciesOverviewResponse(buildKpis(policies), views);
|
||||
}
|
||||
|
||||
/** Display names for every source the caller's team can see, keyed by source id. */
|
||||
private Map<String, String> sourceNames() {
|
||||
Map<String, String> names = new HashMap<>();
|
||||
for (Source source : sourceAccessGuard.visibleFrom(sourceStore)) {
|
||||
names.put(source.id(), source.name());
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
private static PolicyView toView(Policy policy, Map<String, String> sourceNames) {
|
||||
List<PolicyView.SourceRef> sources =
|
||||
policy.sourceIds().stream()
|
||||
// An unresolved id (source deleted, or not visible) falls back to the id so
|
||||
// the row still renders rather than dropping the reference silently.
|
||||
.map(id -> new PolicyView.SourceRef(id, sourceNames.getOrDefault(id, id)))
|
||||
.toList();
|
||||
List<String> steps = policy.steps().stream().map(PipelineStep::operation).toList();
|
||||
return new PolicyView(
|
||||
policy.id(),
|
||||
policy.name(),
|
||||
policy.enabled(),
|
||||
policy.enabled() ? "active" : "paused",
|
||||
triggerSummary(policy.trigger()),
|
||||
sources,
|
||||
steps,
|
||||
outputSummary(policy.output()),
|
||||
policy.owner());
|
||||
}
|
||||
|
||||
/** A null trigger is a manual-only policy; otherwise the trigger's type keys the summary. */
|
||||
private static String triggerSummary(TriggerConfig trigger) {
|
||||
return trigger == null ? "manual" : trigger.type();
|
||||
}
|
||||
|
||||
private static String outputSummary(OutputSpec output) {
|
||||
return output == null ? "inline" : output.type();
|
||||
}
|
||||
|
||||
private static List<PolicyKpi> buildKpis(List<Policy> policies) {
|
||||
long total = policies.size();
|
||||
long active = policies.stream().filter(Policy::enabled).count();
|
||||
long paused = total - active;
|
||||
return List.of(
|
||||
new PolicyKpi(total, "pipelines"),
|
||||
new PolicyKpi(active, "running automatically"),
|
||||
new PolicyKpi(paused, "paused"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package stirling.software.proprietary.policy.overview;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* One row in the Pipelines overview: a stored policy shown for the admin portal, with its
|
||||
* referenced sources resolved to names and its pipeline summarised. The portal's "all pipelines"
|
||||
* surface lists every backend policy (the user-facing Policies page builds only a friendly subset
|
||||
* of these).
|
||||
*/
|
||||
public record PolicyView(
|
||||
String id,
|
||||
String name,
|
||||
boolean enabled,
|
||||
String status,
|
||||
String trigger,
|
||||
List<SourceRef> sources,
|
||||
List<String> steps,
|
||||
String output,
|
||||
String owner) {
|
||||
|
||||
/** A source a policy pulls documents from, resolved to its display name. */
|
||||
public record SourceRef(String id, String name) {}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.policy.config.FolderAccessGuard;
|
||||
import stirling.software.proprietary.policy.engine.PolicyRunner;
|
||||
import stirling.software.proprietary.policy.input.InputSource;
|
||||
import stirling.software.proprietary.policy.model.InputSpec;
|
||||
@@ -74,6 +75,16 @@ public class FolderWatchTrigger implements PolicyTrigger {
|
||||
return TYPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requiresSource() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> supportedSourceTypes() {
|
||||
return Set.of(FolderAccessGuard.FOLDER_TYPE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate(Policy policy) {
|
||||
if (watchDirsOf(policy).isEmpty()) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package stirling.software.proprietary.policy.trigger;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
|
||||
/**
|
||||
@@ -11,6 +13,24 @@ public interface PolicyTrigger {
|
||||
/** Matches {@code TriggerConfig.type()}. */
|
||||
String type();
|
||||
|
||||
/**
|
||||
* Whether this trigger needs at least one compatible input source to function. A schedule fires
|
||||
* on the clock regardless of sources, so it is false; folder-watch derives the directories it
|
||||
* watches from the policy's sources, so it is true. Drives whether the UI offers the trigger.
|
||||
*/
|
||||
default boolean requiresSource() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The source {@code type()}s this trigger is compatible with (e.g. {@code "folder"}). Empty
|
||||
* means source-agnostic (no constraint). Lets the UI offer a trigger only when a compatible
|
||||
* source is selected, without hard-coding the relationship.
|
||||
*/
|
||||
default Set<String> supportedSourceTypes() {
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate at save time so misconfiguration fails fast, not at fire time. Receives the whole
|
||||
* {@link Policy} so triggers that depend on the policy's sources (folder-watch) can check that.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package stirling.software.proprietary.policy.trigger;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Describes an available trigger for the admin UI: its {@code type} (matching {@code
|
||||
* TriggerConfig.type()}), whether it needs a compatible source, and which source types it works
|
||||
* with. Lets the UI list supported triggers and pair them with sources without hard-coding the set.
|
||||
*/
|
||||
public record TriggerInfo(String type, boolean requiresSource, List<String> supportedSourceTypes) {
|
||||
|
||||
public static TriggerInfo of(PolicyTrigger trigger) {
|
||||
return new TriggerInfo(
|
||||
trigger.type(),
|
||||
trigger.requiresSource(),
|
||||
List.copyOf(trigger.supportedSourceTypes()));
|
||||
}
|
||||
}
|
||||
@@ -57,12 +57,23 @@ class PolicyControllerTest {
|
||||
@Mock private PolicyAccessGuard policyAccessGuard;
|
||||
@Mock private PolicyManagementAuthority policyManagementAuthority;
|
||||
@Mock private PolicyTriggerManager policyTriggerManager;
|
||||
|
||||
@Mock
|
||||
private stirling.software.proprietary.policy.overview.PolicyOverviewService
|
||||
policyOverviewService;
|
||||
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@Mock private JobOwnershipService jobOwnershipService;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private PolicyController controller;
|
||||
|
||||
private final java.util.List<stirling.software.proprietary.policy.trigger.PolicyTrigger>
|
||||
policyTriggers =
|
||||
java.util.List.of(
|
||||
trigger("schedule", false, java.util.Set.of()),
|
||||
trigger("folder-watch", true, java.util.Set.of("folder")));
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
@@ -77,11 +88,33 @@ class PolicyControllerTest {
|
||||
policyAccessGuard,
|
||||
policyManagementAuthority,
|
||||
policyTriggerManager,
|
||||
policyOverviewService,
|
||||
policyTriggers,
|
||||
applicationProperties,
|
||||
tempFileManager,
|
||||
jobOwnershipService);
|
||||
}
|
||||
|
||||
private static stirling.software.proprietary.policy.trigger.PolicyTrigger trigger(
|
||||
String type, boolean requiresSource, java.util.Set<String> sourceTypes) {
|
||||
return new stirling.software.proprietary.policy.trigger.PolicyTrigger() {
|
||||
@Override
|
||||
public String type() {
|
||||
return type;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requiresSource() {
|
||||
return requiresSource;
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Set<String> supportedSourceTypes() {
|
||||
return sourceTypes;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static PipelineDefinition definitionWithStep() {
|
||||
return new PipelineDefinition(
|
||||
"pipe", List.of(new PipelineStep("/api/v1/misc/compress-pdf", null)), null);
|
||||
@@ -431,4 +464,50 @@ class PolicyControllerTest {
|
||||
.isEqualTo(HttpStatus.NOT_FOUND));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("triggers / trigger")
|
||||
class Triggers {
|
||||
|
||||
@Test
|
||||
@DisplayName("lists triggers sorted, with source compatibility")
|
||||
void listsTriggers() {
|
||||
List<stirling.software.proprietary.policy.trigger.TriggerInfo> infos =
|
||||
controller.triggers();
|
||||
|
||||
assertThat(infos).extracting(t -> t.type()).containsExactly("folder-watch", "schedule");
|
||||
stirling.software.proprietary.policy.trigger.TriggerInfo folderWatch = infos.get(0);
|
||||
assertThat(folderWatch.requiresSource()).isTrue();
|
||||
assertThat(folderWatch.supportedSourceTypes()).containsExactly("folder");
|
||||
assertThat(infos.get(1).requiresSource()).isFalse();
|
||||
assertThat(infos.get(1).supportedSourceTypes()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("trigger runs an accessible policy against its sources and returns run ids")
|
||||
void triggersRun() {
|
||||
Policy p = policy("a", 1L);
|
||||
when(policyStore.get("a")).thenReturn(Optional.of(p));
|
||||
when(policyAccessGuard.canAccess(p)).thenReturn(true);
|
||||
when(policyRunner.run(p)).thenReturn(List.of("run-a", "run-b"));
|
||||
|
||||
ResponseEntity<List<String>> response = controller.trigger("a");
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
|
||||
assertThat(response.getBody()).containsExactly("run-a", "run-b");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("trigger is 404 when the policy is inaccessible")
|
||||
void triggerNotFound() {
|
||||
when(policyStore.get("z")).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> controller.trigger("z"))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
e ->
|
||||
assertThat(((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.NOT_FOUND));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package stirling.software.proprietary.policy.overview;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.model.PipelineStep;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
import stirling.software.proprietary.policy.model.TriggerConfig;
|
||||
import stirling.software.proprietary.policy.source.InProcessSourceStore;
|
||||
import stirling.software.proprietary.policy.source.Source;
|
||||
import stirling.software.proprietary.policy.source.SourceAccessGuard;
|
||||
import stirling.software.proprietary.policy.source.SourceStore;
|
||||
import stirling.software.proprietary.policy.store.InProcessPolicyStore;
|
||||
import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
|
||||
/**
|
||||
* Tests for {@link PolicyOverviewService}: every policy appears once with its sources resolved to
|
||||
* names, its steps and trigger/output summarised, and the KPI strip counting active vs paused.
|
||||
* Login is disabled so the team guards pass everything through.
|
||||
*/
|
||||
class PolicyOverviewServiceTest {
|
||||
|
||||
private final SourceStore sourceStore = new InProcessSourceStore();
|
||||
private final PolicyStore policyStore = new InProcessPolicyStore();
|
||||
private PolicyOverviewService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ApplicationProperties properties = new ApplicationProperties();
|
||||
properties.getSecurity().setEnableLogin(false);
|
||||
UserServiceInterface userService = mock(UserServiceInterface.class);
|
||||
PolicyManagementAuthority authority = mock(PolicyManagementAuthority.class);
|
||||
SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority);
|
||||
PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority);
|
||||
service = new PolicyOverviewService(policyStore, sourceStore, policyGuard, sourceGuard);
|
||||
}
|
||||
|
||||
@Test
|
||||
void eachPolicyAppearsWithResolvedSourcesStepsAndSummary() {
|
||||
Source claims = source("Claims intake", "/claims");
|
||||
policyStore.save(
|
||||
new Policy(
|
||||
null,
|
||||
"Redaction",
|
||||
"owner",
|
||||
true,
|
||||
new TriggerConfig("schedule", Map.of()),
|
||||
List.of(claims.id()),
|
||||
List.of(new PipelineStep("/api/v1/security/auto-redact", Map.of())),
|
||||
OutputSpec.inline()));
|
||||
policyStore.save(
|
||||
new Policy(
|
||||
null,
|
||||
"Archive (paused)",
|
||||
"owner",
|
||||
false,
|
||||
null,
|
||||
List.of(),
|
||||
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
|
||||
OutputSpec.inline()));
|
||||
|
||||
PoliciesOverviewResponse response = service.overview();
|
||||
|
||||
assertEquals(2, response.pipelines().size());
|
||||
// Sorted by name, case-insensitive, so "Archive" leads "Redaction".
|
||||
PolicyView archive = response.pipelines().get(0);
|
||||
assertEquals("Archive (paused)", archive.name());
|
||||
assertEquals("paused", archive.status());
|
||||
assertEquals("manual", archive.trigger());
|
||||
|
||||
PolicyView redaction = find(response, "Redaction");
|
||||
assertEquals("active", redaction.status());
|
||||
assertEquals("schedule", redaction.trigger());
|
||||
assertEquals("inline", redaction.output());
|
||||
assertEquals(List.of("/api/v1/security/auto-redact"), redaction.steps());
|
||||
assertEquals(1, redaction.sources().size());
|
||||
assertEquals(claims.id(), redaction.sources().get(0).id());
|
||||
assertEquals("Claims intake", redaction.sources().get(0).name());
|
||||
|
||||
// KPI strip: total, active, paused.
|
||||
assertEquals(List.of(2L, 1L, 1L), response.kpis().stream().map(PolicyKpi::value).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnresolvedSourceFallsBackToItsId() {
|
||||
policyStore.save(
|
||||
new Policy(
|
||||
null,
|
||||
"Orphan",
|
||||
"owner",
|
||||
true,
|
||||
null,
|
||||
List.of("src-missing"),
|
||||
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
|
||||
OutputSpec.inline()));
|
||||
|
||||
PolicyView view = find(service.overview(), "Orphan");
|
||||
assertEquals(1, view.sources().size());
|
||||
assertEquals("src-missing", view.sources().get(0).id());
|
||||
assertEquals("src-missing", view.sources().get(0).name());
|
||||
}
|
||||
|
||||
@Test
|
||||
void overviewLoadsOnlyTheCallersTeam() {
|
||||
ApplicationProperties properties = new ApplicationProperties();
|
||||
properties.getSecurity().setEnableLogin(true);
|
||||
UserServiceInterface userService = mock(UserServiceInterface.class);
|
||||
PolicyManagementAuthority authority = mock(PolicyManagementAuthority.class);
|
||||
when(authority.currentUserTeamId()).thenReturn(1L);
|
||||
SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority);
|
||||
PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority);
|
||||
PolicyOverviewService scoped =
|
||||
new PolicyOverviewService(policyStore, sourceStore, policyGuard, sourceGuard);
|
||||
|
||||
Source ours = teamSource("Ours", "/ours", 1L);
|
||||
teamPolicy("Our policy", 1L, ours.id());
|
||||
teamPolicy("Their policy", 2L, ours.id());
|
||||
|
||||
PoliciesOverviewResponse response = scoped.overview();
|
||||
|
||||
assertEquals(1, response.pipelines().size());
|
||||
PolicyView view = response.pipelines().get(0);
|
||||
assertEquals("Our policy", view.name());
|
||||
assertEquals("Ours", view.sources().get(0).name());
|
||||
assertEquals(List.of(1L, 1L, 0L), response.kpis().stream().map(PolicyKpi::value).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyStoreReportsZeroKpis() {
|
||||
PoliciesOverviewResponse response = service.overview();
|
||||
assertTrue(response.pipelines().isEmpty());
|
||||
assertEquals(List.of(0L, 0L, 0L), response.kpis().stream().map(PolicyKpi::value).toList());
|
||||
}
|
||||
|
||||
private Source source(String name, String directory) {
|
||||
return sourceStore.save(
|
||||
new Source(
|
||||
null, name, "folder", Map.of("directory", directory), true, "owner", null));
|
||||
}
|
||||
|
||||
private Source teamSource(String name, String directory, Long teamId) {
|
||||
return sourceStore.save(
|
||||
new Source(
|
||||
null,
|
||||
name,
|
||||
"folder",
|
||||
Map.of("directory", directory),
|
||||
true,
|
||||
"owner",
|
||||
teamId));
|
||||
}
|
||||
|
||||
private void teamPolicy(String name, Long teamId, String... sourceIds) {
|
||||
policyStore.save(
|
||||
new Policy(
|
||||
null,
|
||||
name,
|
||||
"owner",
|
||||
true,
|
||||
null,
|
||||
List.of(sourceIds),
|
||||
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
|
||||
OutputSpec.inline(),
|
||||
teamId));
|
||||
}
|
||||
|
||||
private static PolicyView find(PoliciesOverviewResponse response, String name) {
|
||||
return response.pipelines().stream()
|
||||
.filter(view -> view.name().equals(name))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
}
|
||||
}
|
||||
@@ -5889,6 +5889,17 @@ successMessage = "Your license has been successfully activated. You can now clos
|
||||
deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected."
|
||||
deleteConfirmTitle = "Delete {{label}} policy?"
|
||||
|
||||
[policies.activity]
|
||||
enforced = "enforced"
|
||||
enforcing = "Enforcing..."
|
||||
failed = "Enforcement failed"
|
||||
outputsUnavailable = "Policy outputs are no longer available to download."
|
||||
partialOutputsUnavailable = "Some policy outputs are no longer available to download."
|
||||
retrying = "Busy, retrying..."
|
||||
runNotFound = "The enforcement run could no longer be found."
|
||||
step = "step {{current}}/{{total}}"
|
||||
timedOut = "Enforcement timed out before the run could finish."
|
||||
|
||||
[policies.catalog]
|
||||
compliance = "Compliance"
|
||||
ingestion = "Ingestion"
|
||||
|
||||
@@ -400,6 +400,7 @@ struct SupabaseUser {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SupabaseLoginResponse {
|
||||
access_token: String,
|
||||
refresh_token: Option<String>,
|
||||
user: SupabaseUser,
|
||||
}
|
||||
|
||||
@@ -408,6 +409,7 @@ pub struct LoginResponse {
|
||||
pub token: String,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub refresh_token: Option<String>,
|
||||
}
|
||||
|
||||
/// Login command - makes HTTP request from Rust to bypass CORS
|
||||
@@ -513,6 +515,7 @@ pub async fn login(
|
||||
token: login_response.access_token,
|
||||
username,
|
||||
email,
|
||||
refresh_token: login_response.refresh_token,
|
||||
})
|
||||
} else {
|
||||
// Spring Boot authentication flow
|
||||
@@ -615,6 +618,7 @@ pub async fn login(
|
||||
token: login_response.session.access_token,
|
||||
username: login_response.user.username,
|
||||
email: login_response.user.email,
|
||||
refresh_token: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ import type { LogoVariant } from "@app/services/preferencesService";
|
||||
*/
|
||||
describe("useLogoAssets - Logo Asset Files", () => {
|
||||
const publicDir = path.resolve(__dirname, "../../../public");
|
||||
// Brand logo assets live in the shared design system; the editor's vite
|
||||
// config copies shared/assets/brand/<folder>/* into the served root at build
|
||||
// time (see viteStaticCopy in editor/vite.config.ts), so useLogoAssets can
|
||||
// keep referencing them by their public-URL path. Validate them at source.
|
||||
const brandDir = path.resolve(__dirname, "../../../../shared/assets/brand");
|
||||
|
||||
// All asset files that useLogoAssets references
|
||||
const requiredAssets = [
|
||||
@@ -27,7 +32,7 @@ describe("useLogoAssets - Logo Asset Files", () => {
|
||||
|
||||
describe.each(logoVariants)("%s logo variant", (variant) => {
|
||||
const folder = LOGO_FOLDER_BY_VARIANT[variant];
|
||||
const folderPath = path.join(publicDir, folder);
|
||||
const folderPath = path.join(brandDir, folder);
|
||||
|
||||
test(`folder "${folder}" should exist`, () => {
|
||||
expect(fs.existsSync(folderPath)).toBe(true);
|
||||
|
||||
@@ -30,6 +30,7 @@ interface LoginResponse {
|
||||
token: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
refresh_token: string | null;
|
||||
}
|
||||
|
||||
interface OAuthCallbackResult {
|
||||
@@ -347,11 +348,18 @@ export class AuthService {
|
||||
saasServerUrl: STIRLING_SAAS_URL,
|
||||
});
|
||||
|
||||
const { token, username: returnedUsername, email } = response;
|
||||
const {
|
||||
token,
|
||||
username: returnedUsername,
|
||||
email,
|
||||
refresh_token: refreshToken,
|
||||
} = response;
|
||||
|
||||
// Save token to all storage locations
|
||||
// Save token to all storage locations. Supabase (SaaS) logins include a
|
||||
// refresh token so the short-lived access token can be renewed; self-hosted
|
||||
// logins return null here and refresh via the current access token instead.
|
||||
try {
|
||||
await this.saveTokenEverywhere(token);
|
||||
await this.saveTokenEverywhere(token, refreshToken);
|
||||
} catch (error) {
|
||||
console.error("[Desktop AuthService] Failed to save token:", error);
|
||||
throw new Error("Failed to save authentication token", {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { STIRLING_SAAS_BACKEND_API_URL } from "@app/constants/connection";
|
||||
import type { PolicyExecutionTarget } from "@app/services/policyPipeline";
|
||||
|
||||
/**
|
||||
* Desktop: a policy run's outputs live on the backend that executed it.
|
||||
*/
|
||||
export function getPolicyOutputBaseUrl(target: PolicyExecutionTarget): string {
|
||||
if (target === "saas") {
|
||||
return (STIRLING_SAAS_BACKEND_API_URL ?? "").replace(/\/$/, "");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -18,6 +18,7 @@ function rec(over: Partial<PolicyRunRecord>): PolicyRunRecord {
|
||||
fileId: "f1",
|
||||
fileName: "f.pdf",
|
||||
fileSize: 10,
|
||||
target: "saas",
|
||||
status: "PENDING",
|
||||
outputs: [],
|
||||
error: null,
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { PolicyRunStatus } from "@app/services/policyPipeline";
|
||||
import type {
|
||||
PolicyExecutionTarget,
|
||||
PolicyRunStatus,
|
||||
} from "@app/services/policyPipeline";
|
||||
|
||||
export interface PolicyRunRecord {
|
||||
runId: string;
|
||||
@@ -18,6 +21,7 @@ export interface PolicyRunRecord {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
target: PolicyExecutionTarget;
|
||||
status: PolicyRunStatus;
|
||||
/** Pipeline progress reported by the run-status endpoint: the 1-based step
|
||||
* currently running, and the total step count. Drive the "step X/Y" label
|
||||
@@ -72,6 +76,8 @@ function read(): RunState {
|
||||
importedFileIds: Array.isArray(r.importedFileIds)
|
||||
? r.importedFileIds
|
||||
: [],
|
||||
// Records predating per-run targets all executed on SaaS.
|
||||
target: r.target === "local" ? "local" : "saas",
|
||||
}))
|
||||
: [],
|
||||
dispatched: Array.isArray(parsed.dispatched) ? parsed.dispatched : [],
|
||||
|
||||
@@ -48,6 +48,7 @@ vi.mock("@app/services/policyApi", () => ({
|
||||
getPolicyRun: vi.fn(),
|
||||
listPolicyRuns: mocks.listPolicyRuns,
|
||||
downloadPolicyOutput: mocks.downloadPolicyOutput,
|
||||
resolvePolicyRunTarget: () => "saas",
|
||||
}));
|
||||
vi.mock("@app/services/fileStorage", () => ({
|
||||
fileStorage: {
|
||||
@@ -75,6 +76,7 @@ function recordCompletedRun() {
|
||||
fileId: "file-1",
|
||||
fileName: "doc.pdf",
|
||||
fileSize: 1234,
|
||||
target: "saas",
|
||||
status: "COMPLETED",
|
||||
outputs: [{ fileId: "out-file-1", fileName: "doc.pdf" }],
|
||||
error: null,
|
||||
|
||||
@@ -25,6 +25,7 @@ vi.mock("@app/services/policyApi", () => ({
|
||||
runStoredPolicy: vi.fn(),
|
||||
getPolicyRun: vi.fn(),
|
||||
downloadPolicyOutput: vi.fn(),
|
||||
resolvePolicyRunTarget: () => "saas",
|
||||
}));
|
||||
vi.mock("@app/services/fileStorage", () => ({
|
||||
fileStorage: { getStirlingFile: vi.fn(), getStirlingFileStub: vi.fn() },
|
||||
@@ -80,6 +81,7 @@ describe("auto-run queue-rejection retry", () => {
|
||||
fileId: "file-1",
|
||||
fileName: "doc.pdf",
|
||||
fileSize: 1234,
|
||||
target: "saas",
|
||||
status: "RUNNING",
|
||||
outputs: [],
|
||||
error: null,
|
||||
|
||||
@@ -20,11 +20,13 @@ import {
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
|
||||
import { POLICIES_ENABLED } from "@app/constants/featureFlags";
|
||||
import i18n from "@app/i18n";
|
||||
import {
|
||||
runStoredPolicy,
|
||||
getPolicyRun,
|
||||
listPolicyRuns,
|
||||
downloadPolicyOutput,
|
||||
resolvePolicyRunTarget,
|
||||
} from "@app/services/policyApi";
|
||||
import type {
|
||||
PolicyRunStatus,
|
||||
@@ -82,9 +84,9 @@ const QUEUE_RETRY_BASE_MS = 4000;
|
||||
* to an instance that hasn't seen it) then fail, rather than polling forever. */
|
||||
const MAX_NOT_FOUND = 3;
|
||||
|
||||
/** A 404 from the run-status endpoint, across the web (axios) and desktop
|
||||
* (tauri http client → {@code code: "ERR_NOT_FOUND"}) builds. */
|
||||
function isRunNotFound(err: unknown): boolean {
|
||||
/** A 404 (run status gone, or output file gone), across the web (axios) and
|
||||
* desktop (tauri http client → {@code code: "ERR_NOT_FOUND"}) builds. */
|
||||
function isNotFoundError(err: unknown): boolean {
|
||||
const e = err as
|
||||
| { code?: string; status?: number; response?: { status?: number } }
|
||||
| null
|
||||
@@ -354,6 +356,9 @@ async function reconcileServerRuns(
|
||||
fileId: "",
|
||||
fileName: view.outputs[0]?.fileName ?? "",
|
||||
fileSize: 0,
|
||||
// Rediscovered from the SaaS run registry (listPolicyRuns), so its outputs
|
||||
// live on the cloud backend.
|
||||
target: "saas",
|
||||
status: view.status,
|
||||
outputs: view.outputs,
|
||||
error: view.error,
|
||||
@@ -403,9 +408,9 @@ async function importOutputs(
|
||||
const targetName = ctx.outputName
|
||||
? undefined // use the run's per-output (renamed) name below
|
||||
: run.fileName;
|
||||
const results = await Promise.allSettled(
|
||||
const settled = await Promise.allSettled(
|
||||
pending.map(async (out) => {
|
||||
const blob = await downloadPolicyOutput(out.fileId);
|
||||
const blob = await downloadPolicyOutput(out.fileId, run.target);
|
||||
return {
|
||||
fileId: out.fileId,
|
||||
file: new File([blob], targetName ?? out.fileName ?? run.fileName, {
|
||||
@@ -414,13 +419,33 @@ async function importOutputs(
|
||||
};
|
||||
}),
|
||||
);
|
||||
const fetched = results
|
||||
const fetched = settled
|
||||
.filter(
|
||||
(r): r is PromiseFulfilledResult<{ fileId: string; file: File }> =>
|
||||
r.status === "fulfilled",
|
||||
)
|
||||
.map((r) => r.value);
|
||||
if (fetched.length === 0) return; // all failed — retry the lot on a later tick
|
||||
// A 404 means the backend no longer has that output (past its retention
|
||||
// window); retrying it can never succeed, so don't loop on it forever. Any
|
||||
// other rejection is transient and worth retrying on a later tick.
|
||||
const rejections = settled
|
||||
.filter((r): r is PromiseRejectedResult => r.status === "rejected")
|
||||
.map((r) => r.reason);
|
||||
const allFailuresPermanent =
|
||||
rejections.length > 0 && rejections.every(isNotFoundError);
|
||||
|
||||
if (fetched.length === 0) {
|
||||
if (allFailuresPermanent) {
|
||||
failRun(
|
||||
run.runId,
|
||||
i18n.t(
|
||||
"policies.activity.outputsUnavailable",
|
||||
"Policy outputs are no longer available to download.",
|
||||
),
|
||||
);
|
||||
}
|
||||
return; // transient/mixed: retry the lot later; permanent: already failed.
|
||||
}
|
||||
|
||||
// Deliver, then mark exactly those imported. If delivery throws we don't mark
|
||||
// them, so they retry (without having been added).
|
||||
@@ -472,12 +497,26 @@ async function importOutputs(
|
||||
deliveredIds = added.map((f) => f.fileId as string);
|
||||
}
|
||||
const importedFileIds = [...done, ...fetched.map((f) => f.fileId)];
|
||||
const imported = run.outputs.every((out) =>
|
||||
importedFileIds.includes(out.fileId),
|
||||
);
|
||||
updateRun(run.runId, {
|
||||
importedFileIds,
|
||||
// Accumulate across partial-import retries rather than overwriting.
|
||||
outputFileIds: [...(run.outputFileIds ?? []), ...deliveredIds],
|
||||
imported: run.outputs.every((out) => importedFileIds.includes(out.fileId)),
|
||||
imported,
|
||||
});
|
||||
// Some outputs landed but the rest are permanently gone (404): finalize so the
|
||||
// run stops re-fetching the missing ones on every tick.
|
||||
if (!imported && allFailuresPermanent) {
|
||||
failRun(
|
||||
run.runId,
|
||||
i18n.t(
|
||||
"policies.activity.partialOutputsUnavailable",
|
||||
"Some policy outputs are no longer available to download.",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -515,6 +554,7 @@ export async function runPolicyOnFile(
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const target = resolvePolicyRunTarget();
|
||||
const runId = await runStoredPolicy(backendId, [file]);
|
||||
// recordRunStart marks this (policy, file) dispatched as it records the run.
|
||||
recordRunStart({
|
||||
@@ -523,6 +563,7 @@ export async function runPolicyOnFile(
|
||||
fileId,
|
||||
fileName,
|
||||
fileSize: file.size,
|
||||
target,
|
||||
status: "PENDING",
|
||||
outputs: [],
|
||||
error: null,
|
||||
@@ -562,9 +603,15 @@ export async function poll(
|
||||
// The server lost the run's (in-memory) state — a restart, or a poll that
|
||||
// hopped to an instance without it. Tolerate a brief blip, then fail so
|
||||
// the file stops enforcing forever; the user can retry.
|
||||
if (isRunNotFound(err)) {
|
||||
if (isNotFoundError(err)) {
|
||||
if (++notFoundStreak >= MAX_NOT_FOUND) {
|
||||
failRun(runId, "The enforcement run could no longer be found.");
|
||||
failRun(
|
||||
runId,
|
||||
i18n.t(
|
||||
"policies.activity.runNotFound",
|
||||
"The enforcement run could no longer be found.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@@ -591,5 +638,11 @@ export async function poll(
|
||||
}
|
||||
// Budget exhausted without a terminal status — stop here and fail it, so the
|
||||
// file doesn't enforce forever and reloads don't re-poll it.
|
||||
failRun(runId, "Enforcement timed out — the run didn't finish in time.");
|
||||
failRun(
|
||||
runId,
|
||||
i18n.t(
|
||||
"policies.activity.timedOut",
|
||||
"Enforcement timed out before the run could finish.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ function run(overrides: Partial<PolicyRunRecord>): PolicyRunRecord {
|
||||
fileId: "in",
|
||||
fileName: "in.pdf",
|
||||
fileSize: 1,
|
||||
target: "saas",
|
||||
status: "COMPLETED",
|
||||
outputs: [],
|
||||
outputFileIds: ["out"],
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import SpringLoginForm from "@shared/auth/ui/SpringLoginForm";
|
||||
import { useSpringLogin } from "@shared/auth/ui/useSpringLogin";
|
||||
import LoggedInState from "@app/routes/login/LoggedInState";
|
||||
import loginHeader from "@shared/assets/login/LoginLightModeHeader.svg";
|
||||
import loginHeader from "@shared/assets/brand/modern-logo/LoginLightModeHeader.svg";
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
SignupFieldErrors,
|
||||
} from "@app/routes/signup/SignupFormValidation";
|
||||
import { useAuthService } from "@app/routes/signup/AuthService";
|
||||
import loginHeader from "@shared/assets/login/LoginLightModeHeader.svg";
|
||||
import loginHeader from "@shared/assets/brand/modern-logo/LoginLightModeHeader.svg";
|
||||
|
||||
export default function Signup() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
*/
|
||||
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { getPolicyOutputBaseUrl } from "@app/services/policyOutputBaseUrl";
|
||||
import type {
|
||||
BackendPipelineDefinition,
|
||||
BackendPolicy,
|
||||
PolicyExecutionTarget,
|
||||
PolicyRunView,
|
||||
} from "@app/services/policyPipeline";
|
||||
|
||||
@@ -88,10 +90,25 @@ export async function runPolicyPipeline(
|
||||
return res.data.jobId;
|
||||
}
|
||||
|
||||
/** Download a run's output file by id (via the shared general-files endpoint). */
|
||||
export async function downloadPolicyOutput(fileId: string): Promise<Blob> {
|
||||
/**
|
||||
* Where a policy run executes, and thus the backend that holds its outputs.
|
||||
*/
|
||||
export function resolvePolicyRunTarget(): PolicyExecutionTarget {
|
||||
return "saas";
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a run's output file by id (via the shared general-files endpoint).
|
||||
* `target` is where the run executed: it selects the backend the file is fetched
|
||||
* from, so a SaaS run's output isn't looked for on the bundled local backend.
|
||||
*/
|
||||
export async function downloadPolicyOutput(
|
||||
fileId: string,
|
||||
target: PolicyExecutionTarget,
|
||||
): Promise<Blob> {
|
||||
const base = getPolicyOutputBaseUrl(target);
|
||||
const res = await apiClient.get<Blob>(
|
||||
`/api/v1/general/files/${encodeURIComponent(fileId)}`,
|
||||
`${base}/api/v1/general/files/${encodeURIComponent(fileId)}`,
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
return res.data;
|
||||
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
runStoredPolicy,
|
||||
getPolicyRun,
|
||||
downloadPolicyOutput,
|
||||
resolvePolicyRunTarget,
|
||||
} from "@app/services/policyApi";
|
||||
import type { PolicyExecutionTarget } from "@app/services/policyPipeline";
|
||||
import {
|
||||
recordRunStart,
|
||||
isDispatched,
|
||||
@@ -52,6 +54,7 @@ interface ExportPolicy {
|
||||
interface PolicyRunResult {
|
||||
file: File;
|
||||
runId: string;
|
||||
target: PolicyExecutionTarget;
|
||||
outputs: { fileId: string; fileName: string }[];
|
||||
}
|
||||
|
||||
@@ -84,6 +87,7 @@ async function runToCompletion(
|
||||
backendId: string,
|
||||
file: File,
|
||||
): Promise<PolicyRunResult> {
|
||||
const target = resolvePolicyRunTarget();
|
||||
const runId = await runStoredPolicy(backendId, [file]);
|
||||
for (let i = 0; i < MAX_POLLS; i++) {
|
||||
await delay(POLL_MS);
|
||||
@@ -96,12 +100,12 @@ async function runToCompletion(
|
||||
if (view.status === "COMPLETED") {
|
||||
const out = view.outputs?.[0];
|
||||
if (!out) throw new Error("policy produced no output");
|
||||
const blob = await downloadPolicyOutput(out.fileId);
|
||||
const blob = await downloadPolicyOutput(out.fileId, target);
|
||||
// Keep the export's filename; only the bytes are the enforced result.
|
||||
const enforced = new File([blob], file.name, {
|
||||
type: blob.type || file.type || "application/pdf",
|
||||
});
|
||||
return { file: enforced, runId, outputs: view.outputs ?? [] };
|
||||
return { file: enforced, runId, target, outputs: view.outputs ?? [] };
|
||||
}
|
||||
if (view.status === "FAILED" || view.status === "CANCELLED") {
|
||||
throw new Error(view.error || `policy run ${view.status.toLowerCase()}`);
|
||||
@@ -219,6 +223,7 @@ export async function enforceExportPolicies(
|
||||
fileId,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
target: versionRun!.target,
|
||||
status: "COMPLETED",
|
||||
outputs: versionRun.outputs,
|
||||
error: null,
|
||||
|
||||
@@ -14,6 +14,7 @@ function run(over: Partial<PolicyRunRecord>): PolicyRunRecord {
|
||||
fileId: "f1",
|
||||
fileName: "f.pdf",
|
||||
fileSize: 0,
|
||||
target: "saas",
|
||||
status: "COMPLETED",
|
||||
outputs: [],
|
||||
error: null,
|
||||
@@ -47,7 +48,7 @@ describe("runsToActivity", () => {
|
||||
expect(activity[0]).toMatchObject({
|
||||
doc: "fresh.pdf",
|
||||
status: "processing",
|
||||
action: "Enforcing…",
|
||||
action: "Enforcing...",
|
||||
});
|
||||
expect(activity[1]).toMatchObject({
|
||||
doc: "contract.pdf",
|
||||
@@ -66,9 +67,9 @@ describe("runsToActivity", () => {
|
||||
run({ runId: "a", status: "RUNNING", currentStep: 1, stepCount: 2 }),
|
||||
run({ runId: "b", status: "RUNNING" }),
|
||||
]);
|
||||
expect(withStep.action).toBe("Enforcing… · step 1/2");
|
||||
expect(withStep.action).toBe("Enforcing... · step 1/2");
|
||||
// Before the first status report (no step yet) it stays the plain label.
|
||||
expect(noStep.action).toBe("Enforcing…");
|
||||
expect(noStep.action).toBe("Enforcing...");
|
||||
});
|
||||
|
||||
it("shows a queue-rejected run awaiting retry as busy, not a failure", () => {
|
||||
@@ -81,7 +82,7 @@ describe("runsToActivity", () => {
|
||||
}),
|
||||
]);
|
||||
expect(item.status).toBe("processing");
|
||||
expect(item.action).toBe("Busy — retrying…");
|
||||
expect(item.action).toBe("Busy, retrying...");
|
||||
});
|
||||
|
||||
it("shows a queue rejection that has exhausted its retries as a failure", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* policy's actual enforcement history — not a cosmetic file listing.
|
||||
*/
|
||||
|
||||
import i18n from "@app/i18n";
|
||||
import type { PolicyActivityItem, PolicyStats } from "@app/types/policies";
|
||||
import type { PolicyRunRecord } from "@app/components/policies/policyRunStore";
|
||||
|
||||
@@ -58,17 +59,20 @@ function activityStatus(run: PolicyRunRecord): PolicyActivityItem["status"] {
|
||||
function activityAction(run: PolicyRunRecord): string {
|
||||
switch (activityStatus(run)) {
|
||||
case "enforced":
|
||||
return `${formatBytes(run.fileSize)} • enforced`;
|
||||
return `${formatBytes(run.fileSize)} • ${i18n.t("policies.activity.enforced", "enforced")}`;
|
||||
case "flagged":
|
||||
return run.error ?? "Enforcement failed";
|
||||
return (
|
||||
run.error ?? i18n.t("policies.activity.failed", "Enforcement failed")
|
||||
);
|
||||
default: {
|
||||
if (run.retrying) return "Busy — retrying…";
|
||||
// Show pipeline progress while running, once the status endpoint reports
|
||||
// it — turns a static "Enforcing…" into visible movement on slow steps.
|
||||
if (run.retrying)
|
||||
return i18n.t("policies.activity.retrying", "Busy, retrying...");
|
||||
const enforcing = i18n.t("policies.activity.enforcing", "Enforcing...");
|
||||
// Show pipeline progress while running, once the status endpoint reports it
|
||||
const { currentStep, stepCount } = run;
|
||||
return currentStep && stepCount
|
||||
? `Enforcing… · step ${currentStep}/${stepCount}`
|
||||
: "Enforcing…";
|
||||
? `${enforcing} · ${i18n.t("policies.activity.step", "step {{current}}/{{total}}", { current: currentStep, total: stepCount })}`
|
||||
: enforcing;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { PolicyExecutionTarget } from "@app/services/policyPipeline";
|
||||
|
||||
/**
|
||||
* Base URL for downloading a policy run's output file, given where the run
|
||||
* executed.
|
||||
*
|
||||
* Web builds are served from their own backend, so a relative request resolves
|
||||
* to the right place regardless of where the run ran, hence "" for every
|
||||
* target.
|
||||
*/
|
||||
export function getPolicyOutputBaseUrl(_target: PolicyExecutionTarget): string {
|
||||
return "";
|
||||
}
|
||||
@@ -56,6 +56,12 @@ export interface BackendPolicy {
|
||||
output: BackendOutputSpec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a policy run executes, and therefore where its output files live and
|
||||
* are downloaded from.
|
||||
*/
|
||||
export type PolicyExecutionTarget = "local" | "saas";
|
||||
|
||||
/** Lifecycle states of a backend run (mirrors PolicyRunStatus). */
|
||||
export type PolicyRunStatus =
|
||||
| "PENDING"
|
||||
|
||||
@@ -19,7 +19,7 @@ import ErrorMessage from "@shared/auth/ui/ErrorMessage";
|
||||
import EmailPasswordForm from "@app/routes/login/EmailPasswordForm";
|
||||
import OAuthButtons from "@app/routes/login/OAuthButtons";
|
||||
import LoggedInState from "@app/routes/login/LoggedInState";
|
||||
import loginHeader from "@shared/assets/login/LoginLightModeHeader.svg";
|
||||
import loginHeader from "@shared/assets/brand/modern-logo/LoginLightModeHeader.svg";
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -8,7 +8,7 @@ import "@shared/auth/ui/auth.css";
|
||||
import "@app/routes/authShared/saas-auth.css";
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
import ErrorMessage from "@shared/auth/ui/ErrorMessage";
|
||||
import loginHeader from "@shared/assets/login/LoginLightModeHeader.svg";
|
||||
import loginHeader from "@shared/assets/brand/modern-logo/LoginLightModeHeader.svg";
|
||||
|
||||
/**
|
||||
* OAuth 2.1 consent screen for the Supabase OAuth server (used by MCP clients
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
SignupFieldErrors,
|
||||
} from "@app/routes/signup/SignupFormValidation";
|
||||
import { useAuthService } from "@app/routes/signup/AuthService";
|
||||
import loginHeader from "@shared/assets/login/LoginLightModeHeader.svg";
|
||||
import loginHeader from "@shared/assets/brand/modern-logo/LoginLightModeHeader.svg";
|
||||
|
||||
export default function Signup() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -271,6 +271,17 @@ export default defineConfig(async ({ mode }) => {
|
||||
src: "../node_modules/pdfjs-dist/standard_fonts/*",
|
||||
dest: "pdfjs/standard_fonts",
|
||||
},
|
||||
{
|
||||
// Brand assets live in the shared design system; the editor serves
|
||||
// them by URL per variant, so copy each set to the /{variant}-logo
|
||||
// path its manifests, index.html and useLogoAssets resolve against.
|
||||
src: "../shared/assets/brand/classic-logo/*",
|
||||
dest: "classic-logo",
|
||||
},
|
||||
{
|
||||
src: "../shared/assets/brand/modern-logo/*",
|
||||
dest: "modern-logo",
|
||||
},
|
||||
],
|
||||
}),
|
||||
compressStaticCopyPlugin(),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,39 +1,170 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { PipelinesResponse } from "@portal/mocks/pipelines";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
export type {
|
||||
EvalsNote,
|
||||
GoldenSet,
|
||||
Pipeline,
|
||||
PipelineMetrics,
|
||||
PipelinesResponse,
|
||||
PipelineStatus,
|
||||
PromotedPipeline,
|
||||
PromotedStatus,
|
||||
SchemaDrift,
|
||||
StageKey,
|
||||
StageSummary,
|
||||
} from "@portal/mocks/pipelines";
|
||||
/**
|
||||
* Pipelines service layer: the backend contract.
|
||||
*
|
||||
* A "pipeline" in the portal IS a backend policy (PolicyController, Policy.java):
|
||||
* an ordered chain of tool steps with input sources, a trigger, and an output
|
||||
* destination. This surface lists EVERY backend policy (the user-facing Policies
|
||||
* page builds only a friendly subset of the same records). Like Sources, it calls
|
||||
* the REAL Stirling API base `/api/v1/policies`, so dropping MSW points these exact
|
||||
* calls at the live backend.
|
||||
*/
|
||||
|
||||
/** GET /v1/pipelines?tier=… — the deployed fleet plus tier-specific extras. */
|
||||
export async function fetchPipelines(tier: Tier): Promise<PipelinesResponse> {
|
||||
return apiClient.local.json<PipelinesResponse>(
|
||||
`/v1/pipelines?tier=${encodeURIComponent(tier)}`,
|
||||
);
|
||||
/** One tool invocation in a pipeline. `operation` is a Stirling endpoint path. */
|
||||
export interface PipelineStep {
|
||||
operation: string;
|
||||
parameters: Record<string, unknown>;
|
||||
fileParameters?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** When a policy fires automatically. `type` keys a trigger bean (e.g. "schedule"). */
|
||||
export interface TriggerConfig {
|
||||
type: string;
|
||||
options: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Where a run's outputs are delivered. `type` keys an output sink (e.g. "inline"). */
|
||||
export interface OutputSpec {
|
||||
type: string;
|
||||
options: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote a watch-folder-derived pipeline into a governed org policy, so its
|
||||
* rules apply fleet-wide instead of just to the originating flow.
|
||||
*
|
||||
* TODO(backend): POST /v1/pipelines/{id}/promote-to-policy — should create the
|
||||
* policy from the pipeline's stages and return the new policy id. The mock
|
||||
* handler resolves `{ ok: true }`; the UI treats a resolved promise as accepted.
|
||||
* The stored policy record: the create/update body (`id` blank on create) and what
|
||||
* the backend returns from GET/POST. Mirrors Policy.java exactly; `owner`/`teamId`
|
||||
* are stamped server-side. A `null` trigger means manual-only.
|
||||
*/
|
||||
export async function promoteToPolicy(id: string): Promise<{ ok: true }> {
|
||||
return apiClient.local.json<{ ok: true }>(
|
||||
`/v1/pipelines/${encodeURIComponent(id)}/promote-to-policy`,
|
||||
export interface Policy {
|
||||
id?: string;
|
||||
name: string;
|
||||
owner?: string | null;
|
||||
enabled: boolean;
|
||||
trigger: TriggerConfig | null;
|
||||
sourceIds: string[];
|
||||
steps: PipelineStep[];
|
||||
output: OutputSpec;
|
||||
teamId?: number | null;
|
||||
}
|
||||
|
||||
/** Overview row status: enabled (fires automatically) or paused. */
|
||||
export type PipelineStatus = "active" | "paused";
|
||||
|
||||
/** A source a pipeline pulls documents from, resolved to its display name. */
|
||||
export interface PipelineSourceRef {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** One row in the Pipelines overview. Mirrors the backend `PolicyView`. */
|
||||
export interface PipelineView {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
status: PipelineStatus;
|
||||
/** Trigger summary: "manual" or the trigger type (e.g. "schedule"). */
|
||||
trigger: string;
|
||||
sources: PipelineSourceRef[];
|
||||
/** Operation endpoint paths, in run order. */
|
||||
steps: string[];
|
||||
/** Output sink type (e.g. "inline", "folder"). */
|
||||
output: string;
|
||||
owner: string;
|
||||
}
|
||||
|
||||
export interface PipelineKpi {
|
||||
value: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface PipelinesOverviewResponse {
|
||||
kpis: PipelineKpi[];
|
||||
pipelines: PipelineView[];
|
||||
}
|
||||
|
||||
/** A trigger kind and the source types it works with. Mirrors the backend `TriggerInfo`. */
|
||||
export interface TriggerInfo {
|
||||
/** Matches `TriggerConfig.type` (e.g. "schedule", "folder-watch"). */
|
||||
type: string;
|
||||
/** Whether the trigger needs at least one compatible source to function. */
|
||||
requiresSource: boolean;
|
||||
/** Source types it supports; empty means source-agnostic (no constraint). */
|
||||
supportedSourceTypes: string[];
|
||||
}
|
||||
|
||||
export type PolicyRunStatus =
|
||||
| "PENDING"
|
||||
| "RUNNING"
|
||||
| "WAITING_FOR_INPUT"
|
||||
| "COMPLETED"
|
||||
| "FAILED"
|
||||
| "CANCELLED";
|
||||
|
||||
/** A run's current state. Mirrors the backend `PolicyRunView` (outputs elided). */
|
||||
export interface PolicyRunView {
|
||||
runId: string;
|
||||
policyId: string | null;
|
||||
status: PolicyRunStatus;
|
||||
currentStep: number;
|
||||
stepCount: number;
|
||||
/** Human-readable failure message; set when status is FAILED. */
|
||||
error: string | null;
|
||||
errorCode: string | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** GET /api/v1/policies/overview: KPI strip + one row per policy for the admin. */
|
||||
export async function fetchPipelines(): Promise<PipelinesOverviewResponse> {
|
||||
return apiClient.local.json<PipelinesOverviewResponse>(
|
||||
"/api/v1/policies/overview",
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /api/v1/policies/{id}: the raw policy record (steps, sources, trigger), for editing. */
|
||||
export async function fetchPipeline(id: string): Promise<Policy> {
|
||||
return apiClient.local.json<Policy>(
|
||||
`/api/v1/policies/${encodeURIComponent(id)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** POST /api/v1/policies: create (blank id) or update (matched id) a policy. */
|
||||
export async function savePipeline(policy: Policy): Promise<Policy> {
|
||||
return apiClient.local.json<Policy>("/api/v1/policies", {
|
||||
method: "POST",
|
||||
body: policy,
|
||||
});
|
||||
}
|
||||
|
||||
/** DELETE /api/v1/policies/{id}: remove a policy. */
|
||||
export async function deletePipeline(id: string): Promise<void> {
|
||||
await apiClient.local.json<void>(
|
||||
`/api/v1/policies/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /api/v1/policies/triggers: available triggers + their source compatibility. */
|
||||
export async function fetchTriggers(): Promise<TriggerInfo[]> {
|
||||
return apiClient.local.json<TriggerInfo[]>("/api/v1/policies/triggers");
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/policies/{id}/trigger: run the pipeline now against its configured
|
||||
* sources, regardless of the enabled flag. Returns the ids of the runs started
|
||||
* (empty when the sources yielded no work); poll {@link fetchRun} for each.
|
||||
*/
|
||||
export async function triggerPipeline(id: string): Promise<string[]> {
|
||||
return apiClient.local.json<string[]>(
|
||||
`/api/v1/policies/${encodeURIComponent(id)}/trigger`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /api/v1/policies/run/{runId}: current status, error, and step cursor of a run. */
|
||||
export async function fetchRun(runId: string): Promise<PolicyRunView> {
|
||||
return apiClient.local.json<PolicyRunView>(
|
||||
`/api/v1/policies/run/${encodeURIComponent(runId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import SpringLoginForm from "@shared/auth/ui/SpringLoginForm";
|
||||
import { useSpringLogin } from "@shared/auth/ui/useSpringLogin";
|
||||
import "@shared/auth/ui/auth-theme.css";
|
||||
import "@shared/auth/ui/auth.css";
|
||||
import loginHeader from "@shared/assets/login/LoginLightModeHeader.svg";
|
||||
import loginHeader from "@shared/assets/brand/modern-logo/LoginLightModeHeader.svg";
|
||||
|
||||
/**
|
||||
* Full-screen login shown by the portal's auth gate. Renders the same screen as
|
||||
|
||||
@@ -8,8 +8,8 @@ import { useLink } from "@portal/contexts/LinkContext";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import { fetchHomeKpis, type KpiEntry } from "@portal/api/home";
|
||||
import { EDITOR_URL } from "@portal/auth/editorUrl";
|
||||
import markLight from "@shared/assets/stirling-mark-light.svg";
|
||||
import markDark from "@shared/assets/stirling-mark-dark.svg";
|
||||
import markLight from "@shared/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg";
|
||||
import markDark from "@shared/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg";
|
||||
import {
|
||||
HomeIcon,
|
||||
UsersIcon,
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DeployedPipelinesTable } from "@portal/components/pipelines/DeployedPipelinesTable";
|
||||
import {
|
||||
DEGRADED_PIPELINE,
|
||||
HEALTHY_PIPELINE,
|
||||
} from "@portal/components/pipelines/storyFixtures";
|
||||
import "@portal/views/Pipelines.css";
|
||||
|
||||
const meta: Meta<typeof DeployedPipelinesTable> = {
|
||||
title: "Portal/Pipelines/DeployedPipelinesTable",
|
||||
component: DeployedPipelinesTable,
|
||||
parameters: { layout: "padded" },
|
||||
args: { onRowClick: () => {} },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "72rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DeployedPipelinesTable>;
|
||||
|
||||
/** A healthy pipeline at bound and one degraded below its golden-set bound. */
|
||||
export const Default: Story = {
|
||||
args: { pipelines: [HEALTHY_PIPELINE, DEGRADED_PIPELINE] },
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: { pipelines: [] },
|
||||
};
|
||||
@@ -1,106 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StatusBadge, Table, type TableColumn } from "@shared/components";
|
||||
import type { Pipeline } from "@portal/api/pipelines";
|
||||
import { compact, goldenTone, pct } from "@portal/components/pipelines/format";
|
||||
|
||||
interface DeployedPipelinesTableProps {
|
||||
pipelines: Pipeline[];
|
||||
onRowClick: (p: Pipeline) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dense roster of the deployed fleet that puts golden-set reliability up front.
|
||||
* The card list below it carries the full per-pipeline story; this table is the
|
||||
* scannable "is anything below its bound?" view across the whole fleet.
|
||||
*/
|
||||
export function DeployedPipelinesTable({
|
||||
pipelines,
|
||||
onRowClick,
|
||||
}: DeployedPipelinesTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const columns = useMemo<TableColumn<Pipeline>[]>(
|
||||
() => [
|
||||
{
|
||||
key: "name",
|
||||
header: t("pipelines.table.header.name"),
|
||||
render: (p) => (
|
||||
<div className="portal-pipelines__roster-name">
|
||||
<strong>{p.name}</strong>
|
||||
<span className="portal-pipelines__roster-route">
|
||||
{p.source} → {p.destination}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: t("pipelines.table.header.health"),
|
||||
render: (p) => (
|
||||
<StatusBadge
|
||||
tone={p.status === "degraded" ? "warning" : "success"}
|
||||
size="sm"
|
||||
pulse={p.status === "degraded"}
|
||||
>
|
||||
{p.status === "degraded"
|
||||
? t("pipelines.status.degraded")
|
||||
: t("pipelines.status.healthy")}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "golden",
|
||||
header: t("pipelines.table.header.goldenSet"),
|
||||
width: "11rem",
|
||||
render: (p) => {
|
||||
const tone = goldenTone(p.golden);
|
||||
const rate = p.golden.total ? p.golden.passing / p.golden.total : 0;
|
||||
return (
|
||||
<div className="portal-pipelines__roster-golden">
|
||||
<StatusBadge tone={tone} size="sm">
|
||||
{p.golden.passing}/{p.golden.total}
|
||||
</StatusBadge>
|
||||
<span
|
||||
className="portal-pipelines__roster-rate"
|
||||
title={t("pipelines.table.boundTooltip", {
|
||||
bound: pct(p.golden.threshold, 0),
|
||||
})}
|
||||
>
|
||||
{pct(rate, 1)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "docs",
|
||||
header: t("pipelines.table.header.docs24h"),
|
||||
align: "right",
|
||||
render: (p) => (
|
||||
<span className="portal-pipelines__roster-num">
|
||||
{compact(p.metrics.docs24h)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "version",
|
||||
header: t("pipelines.table.header.version"),
|
||||
align: "right",
|
||||
render: (p) => (
|
||||
<span className="portal-pipelines__roster-version">{p.version}</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Table<Pipeline>
|
||||
className="portal-pipelines__roster"
|
||||
columns={columns}
|
||||
rows={pipelines}
|
||||
rowKey={(p) => p.id}
|
||||
onRowClick={onRowClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MetricCard, MetricStrip } from "@shared/components";
|
||||
import type { PipelinesOverviewResponse } from "@portal/api/pipelines";
|
||||
|
||||
/**
|
||||
* KPI labels are product copy: they describe what each metric IS, not its value.
|
||||
* They stay client-side so the strip's structure is stable across loading / empty
|
||||
* / ready states; only values + descriptions flow from the API. Order matches
|
||||
* PolicyOverviewService.buildKpis: total, active, paused.
|
||||
*/
|
||||
const KPI_LABEL_KEYS = [
|
||||
"pipelines.kpi.total",
|
||||
"pipelines.kpi.active",
|
||||
"pipelines.kpi.paused",
|
||||
] as const;
|
||||
|
||||
interface KpiStripProps {
|
||||
data: PipelinesOverviewResponse | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function KpiStrip({ data, loading }: KpiStripProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<MetricStrip>
|
||||
{KPI_LABEL_KEYS.map((labelKey, i) => {
|
||||
const k = loading ? undefined : data?.kpis[i];
|
||||
return (
|
||||
<MetricCard
|
||||
key={labelKey}
|
||||
label={t(labelKey)}
|
||||
value={k?.value ?? "—"}
|
||||
description={k?.description}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</MetricStrip>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { PipelineCard } from "@portal/components/pipelines/PipelineCard";
|
||||
import {
|
||||
DEGRADED_PIPELINE,
|
||||
HEALTHY_PIPELINE,
|
||||
} from "@portal/components/pipelines/storyFixtures";
|
||||
|
||||
const meta: Meta<typeof PipelineCard> = {
|
||||
title: "Portal/Pipelines/PipelineCard",
|
||||
component: PipelineCard,
|
||||
parameters: { layout: "padded" },
|
||||
args: { onOpen: () => console.log("open") },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "52rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PipelineCard>;
|
||||
|
||||
export const Healthy: Story = {
|
||||
args: { pipeline: HEALTHY_PIPELINE },
|
||||
};
|
||||
|
||||
export const DegradedWithDrift: Story = {
|
||||
args: { pipeline: DEGRADED_PIPELINE },
|
||||
};
|
||||
@@ -1,132 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, StatTile, StatusBadge } from "@shared/components";
|
||||
import type { Pipeline, StageSummary } from "@portal/api/pipelines";
|
||||
import {
|
||||
STAGE_ACCENT,
|
||||
STAGE_COLOR_VAR,
|
||||
} from "@portal/components/pipelines/stageAccent";
|
||||
import { compact, pct } from "@portal/components/pipelines/format";
|
||||
|
||||
/** Compact five-dot stage indicator: a lit dot per stage that has ops. */
|
||||
function StageDots({ stages }: { stages: StageSummary[] }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span className="portal-pipelines__stage-dots" aria-hidden>
|
||||
{stages.map((s) => (
|
||||
<span
|
||||
key={s.key}
|
||||
className="portal-pipelines__stage-dot"
|
||||
style={{
|
||||
background: s.ops.length
|
||||
? STAGE_COLOR_VAR[STAGE_ACCENT[s.key]]
|
||||
: "var(--color-border)",
|
||||
}}
|
||||
title={t("pipelines.card.stageTooltip", {
|
||||
label: s.label,
|
||||
count: s.ops.length,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PipelineCardProps {
|
||||
pipeline: Pipeline;
|
||||
onOpen: (p: Pipeline) => void;
|
||||
}
|
||||
|
||||
/** Row in the deployed fleet: health, source→stages→destination rail, 24h metrics. */
|
||||
export function PipelineCard({ pipeline, onOpen }: PipelineCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const m = pipeline.metrics;
|
||||
const degraded = pipeline.status === "degraded";
|
||||
const errorTone =
|
||||
m.errorRate >= 0.02
|
||||
? "danger"
|
||||
: m.errorRate >= 0.01
|
||||
? "warning"
|
||||
: "default";
|
||||
const driftCount = pipeline.drift.length;
|
||||
|
||||
return (
|
||||
<Card
|
||||
padding="loose"
|
||||
interactive
|
||||
className="portal-pipelines__card"
|
||||
onClick={() => onOpen(pipeline)}
|
||||
>
|
||||
<div className="portal-pipelines__card-head">
|
||||
<div className="portal-pipelines__card-titles">
|
||||
<h3 className="portal-pipelines__card-title">{pipeline.name}</h3>
|
||||
<p className="portal-pipelines__card-blurb">{pipeline.blurb}</p>
|
||||
</div>
|
||||
<StatusBadge
|
||||
tone={degraded ? "warning" : "success"}
|
||||
size="sm"
|
||||
pulse={degraded}
|
||||
>
|
||||
{degraded
|
||||
? t("pipelines.status.degraded")
|
||||
: t("pipelines.status.healthy")}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
|
||||
<div className="portal-pipelines__card-rail">
|
||||
<span className="portal-pipelines__rail-chip">{pipeline.source}</span>
|
||||
<span className="portal-pipelines__rail-arrow" aria-hidden>
|
||||
→
|
||||
</span>
|
||||
<StageDots stages={pipeline.stages} />
|
||||
<span className="portal-pipelines__rail-arrow" aria-hidden>
|
||||
→
|
||||
</span>
|
||||
<span className="portal-pipelines__rail-chip">
|
||||
{pipeline.destination}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="portal-pipelines__metrics">
|
||||
<StatTile
|
||||
label={t("pipelines.metrics.docs24h")}
|
||||
value={compact(m.docs24h)}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("pipelines.metrics.throughput")}
|
||||
value={`${m.throughputPerMin}/min`}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("pipelines.metrics.errorRate")}
|
||||
value={pct(m.errorRate, 2)}
|
||||
tone={errorTone}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("pipelines.metrics.p95Latency")}
|
||||
value={`${m.p95LatencyMs} ms`}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("pipelines.metrics.uptime")}
|
||||
value={pct(m.uptime, 2)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="portal-pipelines__card-foot">
|
||||
<span className="portal-pipelines__card-version">
|
||||
{pipeline.version} · {pipeline.regions.join(", ")}
|
||||
</span>
|
||||
<span className="portal-pipelines__card-golden">
|
||||
{t("pipelines.card.golden", {
|
||||
passing: pipeline.golden.passing,
|
||||
total: pipeline.golden.total,
|
||||
})}
|
||||
{driftCount > 0 && (
|
||||
<span className="portal-pipelines__card-drift">
|
||||
{" · "}
|
||||
{t("pipelines.card.drift", { count: driftCount })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { PipelineComposer } from "@portal/components/pipelines/PipelineComposer";
|
||||
|
||||
const meta: Meta<typeof PipelineComposer> = {
|
||||
title: "Portal/Pipelines/PipelineComposer",
|
||||
component: PipelineComposer,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: { open: true, onClose: () => console.log("close") },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PipelineComposer>;
|
||||
|
||||
/** Opens on the source step; step through Operations and Routing in the footer. */
|
||||
export const Open: Story = {};
|
||||
|
||||
export const Closed: Story = {
|
||||
args: { open: false },
|
||||
};
|
||||
@@ -1,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<OpKind, string> = {
|
||||
ingest: "ingest",
|
||||
validate: "validate",
|
||||
modify: "modify",
|
||||
secure: "secure",
|
||||
store: "store",
|
||||
alert: "alert",
|
||||
};
|
||||
const SCHEDULE_UNITS: ScheduleUnit[] = ["MINUTES", "HOURS", "DAYS"];
|
||||
/** Empty trigger type = manual-only (no automatic trigger). */
|
||||
const MANUAL = "";
|
||||
|
||||
/** Selectable ops in the picker — excludes pipeline-only structural ops. */
|
||||
const PICKER_OPS: Record<OpKind, PipelineOp[]> = (() => {
|
||||
const out = {} as Record<OpKind, PipelineOp[]>;
|
||||
for (const kind of Object.keys(PIPELINE_OPS) as OpKind[]) {
|
||||
out[kind] = PIPELINE_OPS[kind].filter((op) => !op.pipelineOnly);
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
|
||||
function lookupPickerOp(id: string): PipelineOp | null {
|
||||
for (const kind of Object.keys(PICKER_OPS) as OpKind[]) {
|
||||
const found = PICKER_OPS[kind].find((op) => op.id === id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface PipelineComposerProps {
|
||||
interface PipelineComposerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Called after a pipeline is created or updated so the page can refetch. */
|
||||
onSaved: () => void;
|
||||
/** When set, the composer edits this existing policy instead of creating one. */
|
||||
pipeline?: Policy;
|
||||
}
|
||||
|
||||
/** Three-step wizard: pick a source, compose the op chain, route the output. */
|
||||
export function PipelineComposer({ open, onClose }: PipelineComposerProps) {
|
||||
/**
|
||||
* A policy's trigger parsed into the composer's fields: which trigger type (empty
|
||||
* = manual), and the schedule interval when it's a schedule trigger.
|
||||
*/
|
||||
function parseTrigger(trigger: TriggerConfig | null): {
|
||||
triggerType: string;
|
||||
count: string;
|
||||
unit: ScheduleUnit;
|
||||
} {
|
||||
if (!trigger) return { triggerType: MANUAL, count: "1", unit: "HOURS" };
|
||||
if (trigger.type === "schedule") {
|
||||
const schedule = trigger.options?.schedule as
|
||||
| { type?: string; count?: number; unit?: ScheduleUnit }
|
||||
| undefined;
|
||||
if (schedule?.type === "every") {
|
||||
return {
|
||||
triggerType: "schedule",
|
||||
count: String(schedule.count ?? 1),
|
||||
unit: schedule.unit ?? "HOURS",
|
||||
};
|
||||
}
|
||||
// Non-interval schedules (daily/weekly/monthly) aren't editable here; show
|
||||
// the schedule choice with defaults the user can re-set.
|
||||
return { triggerType: "schedule", count: "1", unit: "HOURS" };
|
||||
}
|
||||
return { triggerType: trigger.type, count: "1", unit: "HOURS" };
|
||||
}
|
||||
|
||||
/** Output sink fields parsed from a policy's output for editing. */
|
||||
function parseOutput(output: OutputSpec | undefined): {
|
||||
mode: OutputMode;
|
||||
directory: string;
|
||||
} {
|
||||
if (output?.type === "folder") {
|
||||
return {
|
||||
mode: "folder",
|
||||
directory: String(output.options?.directory ?? ""),
|
||||
};
|
||||
}
|
||||
return { mode: "inline", directory: "" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose a pipeline (a backend policy): name it, pick the sources it pulls from
|
||||
* and how it's triggered, chain operations, and choose where output goes. On submit
|
||||
* a blank id creates and a set id updates, matching the backend's POST contract.
|
||||
* Per-operation parameter editing is out of scope here; operations are chained with
|
||||
* their defaults.
|
||||
*/
|
||||
export function PipelineComposer({
|
||||
open,
|
||||
onClose,
|
||||
onSaved,
|
||||
pipeline,
|
||||
}: PipelineComposerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState(0);
|
||||
const [source, setSource] = useState<string>("upload");
|
||||
const [selectedOps, setSelectedOps] = useState<string[]>([
|
||||
"extract",
|
||||
"validate",
|
||||
"redact",
|
||||
]);
|
||||
const [destination, setDestination] = useState<string>("vault");
|
||||
const [notifyEmail, setNotifyEmail] = useState(true);
|
||||
const [notifyWebhook, setNotifyWebhook] = useState(false);
|
||||
const [reviewQueue, setReviewQueue] = useState(true);
|
||||
const isEdit = pipeline !== undefined;
|
||||
|
||||
function reset() {
|
||||
setStep(0);
|
||||
setSource("upload");
|
||||
setSelectedOps(["extract", "validate", "redact"]);
|
||||
setDestination("vault");
|
||||
setNotifyEmail(true);
|
||||
setNotifyWebhook(false);
|
||||
setReviewQueue(true);
|
||||
}
|
||||
const sourcesState = useAsync<SourceView[]>(
|
||||
async () => (open ? (await fetchSources()).sources : []),
|
||||
[open],
|
||||
);
|
||||
const availableSources = sourcesState.data ?? [];
|
||||
|
||||
function close() {
|
||||
onClose();
|
||||
// Defer reset so it doesn't flash mid-close-animation.
|
||||
setTimeout(reset, 0);
|
||||
}
|
||||
// The triggers the backend supports, with their source-type compatibility, so
|
||||
// the UI offers them (and pairs them with sources) without hard-coding the set.
|
||||
const triggersState = useAsync<TriggerInfo[]>(
|
||||
async () => (open ? await fetchTriggers() : []),
|
||||
[open],
|
||||
);
|
||||
const triggers = useMemo(
|
||||
() => triggersState.data ?? [],
|
||||
[triggersState.data],
|
||||
);
|
||||
|
||||
function deploy() {
|
||||
// TODO(backend): POST /v1/pipelines { source, ops: selectedOps, destination, alerts }
|
||||
close();
|
||||
}
|
||||
const [name, setName] = useState("");
|
||||
const [sourceIds, setSourceIds] = useState<string[]>([]);
|
||||
const [steps, setSteps] = useState<PipelineStep[]>([]);
|
||||
const [triggerType, setTriggerType] = useState<string>(MANUAL);
|
||||
const [scheduleCount, setScheduleCount] = useState("1");
|
||||
const [scheduleUnit, setScheduleUnit] = useState<ScheduleUnit>("HOURS");
|
||||
const [outputMode, setOutputMode] = useState<OutputMode>("inline");
|
||||
const [outputDirectory, setOutputDirectory] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function toggleOp(id: string) {
|
||||
setSelectedOps((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
||||
// Re-seed the form whenever the composer opens (or its target changes) so editing
|
||||
// prefills the current config and a reopened create starts clean.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const trigger = parseTrigger(pipeline?.trigger ?? null);
|
||||
const output = parseOutput(pipeline?.output);
|
||||
setName(pipeline?.name ?? "");
|
||||
setSourceIds(pipeline?.sourceIds ?? []);
|
||||
setSteps(pipeline?.steps ?? []);
|
||||
setTriggerType(trigger.triggerType);
|
||||
setScheduleCount(trigger.count);
|
||||
setScheduleUnit(trigger.unit);
|
||||
setOutputMode(output.mode);
|
||||
setOutputDirectory(output.directory);
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
}, [open, pipeline]);
|
||||
|
||||
// Types of the currently-selected sources, for trigger compatibility.
|
||||
const selectedSourceTypes = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
availableSources
|
||||
.filter((s) => sourceIds.includes(s.id))
|
||||
.map((s) => s.type),
|
||||
),
|
||||
[availableSources, sourceIds],
|
||||
);
|
||||
|
||||
const triggerAvailable = useMemo(
|
||||
() => (trigger: TriggerInfo) =>
|
||||
!trigger.requiresSource ||
|
||||
trigger.supportedSourceTypes.some((type) =>
|
||||
selectedSourceTypes.has(type),
|
||||
),
|
||||
[selectedSourceTypes],
|
||||
);
|
||||
|
||||
// A source-requiring trigger stops being valid the moment its compatible source
|
||||
// is deselected; fall back to manual so we never submit an impossible trigger.
|
||||
useEffect(() => {
|
||||
if (triggerType === MANUAL) return;
|
||||
const selected = triggers.find((trigger) => trigger.type === triggerType);
|
||||
if (selected && !triggerAvailable(selected)) setTriggerType(MANUAL);
|
||||
}, [triggerType, triggers, triggerAvailable]);
|
||||
|
||||
function toggleSource(id: string, checked: boolean) {
|
||||
setSourceIds((ids) =>
|
||||
checked ? [...ids, id] : ids.filter((existing) => existing !== id),
|
||||
);
|
||||
}
|
||||
|
||||
function applyAgent(ops: string[]) {
|
||||
// A bundle lists its full op set, but structural rails (retention,
|
||||
// residency, access policy) aren't user-chainable picker ops — add only
|
||||
// ops that exist in the picker so every chain chip resolves to a label.
|
||||
const pickable = ops.filter((id) => lookupPickerOp(id) !== null);
|
||||
setSelectedOps((prev) => Array.from(new Set([...prev, ...pickable])));
|
||||
function addStep(operation: string, parameters: Record<string, unknown>) {
|
||||
setSteps((current) => [
|
||||
...current,
|
||||
{ operation, parameters: { ...parameters } },
|
||||
]);
|
||||
}
|
||||
|
||||
const isLast = step === COMPOSER_STEPS.length - 1;
|
||||
const canAdvance = step === 1 ? selectedOps.length > 0 : true;
|
||||
function removeStep(index: number) {
|
||||
setSteps((current) => current.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function moveStep(index: number, delta: number) {
|
||||
setSteps((current) => {
|
||||
const next = [...current];
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= next.length) return current;
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const scheduleCountValid =
|
||||
triggerType !== "schedule" || Number(scheduleCount) > 0;
|
||||
const outputValid = outputMode !== "folder" || outputDirectory.trim() !== "";
|
||||
const canSave =
|
||||
name.trim() !== "" && scheduleCountValid && outputValid && !submitting;
|
||||
|
||||
const triggerOptions = [
|
||||
{ value: MANUAL, label: t("pipelines.composer.triggerManual") },
|
||||
...triggers.map((trigger) => ({
|
||||
value: trigger.type,
|
||||
label: t(`pipelines.trigger.${trigger.type}`, {
|
||||
defaultValue: trigger.type,
|
||||
}),
|
||||
disabled: !triggerAvailable(trigger),
|
||||
})),
|
||||
];
|
||||
|
||||
function buildTrigger(): TriggerConfig | null {
|
||||
if (triggerType === MANUAL) return null;
|
||||
if (triggerType === "schedule") {
|
||||
return {
|
||||
type: "schedule",
|
||||
options: {
|
||||
schedule: {
|
||||
type: "every",
|
||||
count: Number(scheduleCount),
|
||||
unit: scheduleUnit,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return { type: triggerType, options: {} };
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!canSave) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
const output: OutputSpec =
|
||||
outputMode === "folder"
|
||||
? { type: "folder", options: { directory: outputDirectory.trim() } }
|
||||
: { type: "inline", options: {} };
|
||||
const policy: Policy = {
|
||||
id: pipeline?.id,
|
||||
name: name.trim(),
|
||||
enabled: pipeline?.enabled ?? true,
|
||||
trigger: buildTrigger(),
|
||||
sourceIds,
|
||||
steps,
|
||||
output,
|
||||
};
|
||||
try {
|
||||
await savePipeline(policy);
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={close}
|
||||
width="xl"
|
||||
title={t("pipelines.composer.title")}
|
||||
onClose={onClose}
|
||||
width="lg"
|
||||
title={
|
||||
isEdit
|
||||
? t("pipelines.composer.editTitle")
|
||||
: t("pipelines.composer.title")
|
||||
}
|
||||
subtitle={t("pipelines.composer.subtitle")}
|
||||
footer={
|
||||
<>
|
||||
<div className="portal-pipelines__composer-steps" aria-hidden>
|
||||
{COMPOSER_STEPS.map((stepId, i) => (
|
||||
<span
|
||||
key={stepId}
|
||||
className={
|
||||
"portal-pipelines__composer-step" +
|
||||
(i === step ? " is-active" : i < step ? " is-done" : "")
|
||||
}
|
||||
>
|
||||
{i + 1}. {t(`pipelines.composer.steps.${stepId}`)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="ghost" onClick={close}>
|
||||
<div className="portal-pipelines__composer-footer">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={submitting}
|
||||
onClick={onClose}
|
||||
>
|
||||
{t("pipelines.composer.cancel")}
|
||||
</Button>
|
||||
{step > 0 && (
|
||||
<Button variant="outline" onClick={() => setStep((s) => s - 1)}>
|
||||
{t("pipelines.composer.back")}
|
||||
</Button>
|
||||
)}
|
||||
{isLast ? (
|
||||
<Button
|
||||
variant="gradient"
|
||||
onClick={deploy}
|
||||
trailingIcon={<span aria-hidden>→</span>}
|
||||
>
|
||||
{t("pipelines.composer.deploy")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="gradient"
|
||||
onClick={() => setStep((s) => s + 1)}
|
||||
disabled={!canAdvance}
|
||||
trailingIcon={<span aria-hidden>→</span>}
|
||||
>
|
||||
{t("pipelines.composer.continue")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={submit}
|
||||
loading={submitting}
|
||||
disabled={!canSave}
|
||||
>
|
||||
{isEdit
|
||||
? t("pipelines.composer.save")
|
||||
: t("pipelines.composer.create")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="portal-pipelines__composer">
|
||||
{step === 0 && (
|
||||
<div className="portal-pipelines__composer-body">
|
||||
<div className="portal-pipelines__composer-grid">
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
"portal-pipelines__option" +
|
||||
(source === "any" ? " is-selected" : "")
|
||||
<FormField label={t("pipelines.composer.name")} required>
|
||||
<Input
|
||||
value={name}
|
||||
placeholder={t("pipelines.composer.namePlaceholder")}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Sources */}
|
||||
<div className="portal-pipelines__composer-section">
|
||||
<span className="portal-pipelines__detail-heading">
|
||||
{t("pipelines.composer.sources")}
|
||||
</span>
|
||||
{sourcesState.loading ? (
|
||||
<p className="portal-pipelines__muted">
|
||||
{t("pipelines.composer.sourcesLoading")}
|
||||
</p>
|
||||
) : availableSources.length === 0 ? (
|
||||
<p className="portal-pipelines__muted">
|
||||
{t("pipelines.composer.noSources")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="portal-pipelines__source-list">
|
||||
{availableSources.map((source) => (
|
||||
<Checkbox
|
||||
key={source.id}
|
||||
checked={sourceIds.includes(source.id)}
|
||||
onChange={(e) => toggleSource(source.id, e.target.checked)}
|
||||
label={source.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="portal-pipelines__detail-heading">
|
||||
{t("pipelines.composer.trigger")}
|
||||
</span>
|
||||
<RadioGroup<string>
|
||||
name="pipeline-trigger"
|
||||
value={triggerType}
|
||||
onChange={setTriggerType}
|
||||
direction="horizontal"
|
||||
options={triggerOptions}
|
||||
/>
|
||||
{triggerType === "schedule" && (
|
||||
<div className="portal-pipelines__schedule">
|
||||
<span className="portal-pipelines__muted">
|
||||
{t("pipelines.composer.scheduleEvery")}
|
||||
</span>
|
||||
<Input
|
||||
inputSize="sm"
|
||||
type="number"
|
||||
min={1}
|
||||
value={scheduleCount}
|
||||
invalid={!scheduleCountValid}
|
||||
onChange={(e) => setScheduleCount(e.target.value)}
|
||||
className="portal-pipelines__schedule-count"
|
||||
/>
|
||||
<Select
|
||||
inputSize="sm"
|
||||
value={scheduleUnit}
|
||||
onChange={(e) =>
|
||||
setScheduleUnit(e.target.value as ScheduleUnit)
|
||||
}
|
||||
onClick={() => setSource("any")}
|
||||
options={SCHEDULE_UNITS.map((unit) => ({
|
||||
value: unit,
|
||||
label: t(`pipelines.composer.unit.${unit.toLowerCase()}`),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Operations */}
|
||||
<div className="portal-pipelines__composer-section">
|
||||
<span className="portal-pipelines__detail-heading">
|
||||
{t("pipelines.composer.operations", { count: steps.length })}
|
||||
</span>
|
||||
{steps.length === 0 ? (
|
||||
<p className="portal-pipelines__muted">
|
||||
{t("pipelines.composer.chainEmpty")}
|
||||
</p>
|
||||
) : (
|
||||
<ol className="portal-pipelines__chain">
|
||||
{steps.map((step, i) => (
|
||||
<li
|
||||
key={`${step.operation}-${i}`}
|
||||
className="portal-pipelines__chain-row"
|
||||
>
|
||||
<span className="portal-pipelines__chain-index">{i + 1}</span>
|
||||
<span className="portal-pipelines__chain-op">
|
||||
{humanizeOperation(step.operation)}
|
||||
</span>
|
||||
<div className="portal-pipelines__chain-actions">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("pipelines.composer.moveUp")}
|
||||
disabled={i === 0}
|
||||
onClick={() => moveStep(i, -1)}
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("pipelines.composer.moveDown")}
|
||||
disabled={i === steps.length - 1}
|
||||
onClick={() => moveStep(i, 1)}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("pipelines.composer.removeStep")}
|
||||
onClick={() => removeStep(i)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
<div className="portal-pipelines__op-palette">
|
||||
{PIPELINE_OPERATIONS.map((op) => (
|
||||
<Chip
|
||||
key={op.operation}
|
||||
tone="blue"
|
||||
size="sm"
|
||||
onClick={() => addStep(op.operation, op.parameters)}
|
||||
>
|
||||
<span className="portal-pipelines__option-label">
|
||||
{t("pipelines.composer.anySource.label")}
|
||||
</span>
|
||||
<span className="portal-pipelines__option-desc">
|
||||
{t("pipelines.composer.anySource.desc")}
|
||||
</span>
|
||||
</button>
|
||||
{SOURCE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
className={
|
||||
"portal-pipelines__option" +
|
||||
(source === opt.id ? " is-selected" : "")
|
||||
}
|
||||
onClick={() => setSource(opt.id)}
|
||||
>
|
||||
<span className="portal-pipelines__option-label">
|
||||
{opt.label}
|
||||
</span>
|
||||
<span className="portal-pipelines__option-desc">
|
||||
{opt.desc}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{`+ ${humanizeOperation(op.operation)}`}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{step === 1 && (
|
||||
<div className="portal-pipelines__composer-body">
|
||||
<div className="portal-pipelines__chain">
|
||||
<span className="portal-pipelines__chain-label">
|
||||
{t("pipelines.composer.operationChain", {
|
||||
count: selectedOps.length,
|
||||
})}
|
||||
</span>
|
||||
<div className="portal-pipelines__chain-chips">
|
||||
{selectedOps.length === 0 ? (
|
||||
<span className="portal-pipelines__chain-empty">
|
||||
{t("pipelines.composer.chainEmpty")}
|
||||
</span>
|
||||
) : (
|
||||
selectedOps.map((id) => {
|
||||
const op = lookupPickerOp(id);
|
||||
const accent = op ? OP_KIND_ACCENT[op.kind] : "purple";
|
||||
return (
|
||||
<Chip
|
||||
key={id}
|
||||
tone={accent}
|
||||
size="sm"
|
||||
onRemove={() => toggleOp(id)}
|
||||
>
|
||||
{op?.label ?? id}
|
||||
</Chip>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Output */}
|
||||
<div className="portal-pipelines__composer-section">
|
||||
<span className="portal-pipelines__detail-heading">
|
||||
{t("pipelines.composer.output")}
|
||||
</span>
|
||||
<RadioGroup<OutputMode>
|
||||
name="pipeline-output"
|
||||
value={outputMode}
|
||||
onChange={setOutputMode}
|
||||
direction="horizontal"
|
||||
options={[
|
||||
{ value: "inline", label: t("pipelines.output.inline") },
|
||||
{ value: "folder", label: t("pipelines.output.folder") },
|
||||
]}
|
||||
/>
|
||||
{outputMode === "folder" && (
|
||||
<FormField
|
||||
label={t("pipelines.composer.directory")}
|
||||
helperText={t("pipelines.composer.directoryHelp")}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
value={outputDirectory}
|
||||
placeholder="/data/processed"
|
||||
onChange={(e) => setOutputDirectory(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="portal-pipelines__agents">
|
||||
<span className="portal-pipelines__agents-label">
|
||||
{t("pipelines.composer.quickAddBundles")}
|
||||
</span>
|
||||
<div className="portal-pipelines__agents-row">
|
||||
{PIPELINE_AGENTS.map((agent) => (
|
||||
<Chip
|
||||
key={agent.id}
|
||||
tone="neutral"
|
||||
size="sm"
|
||||
onClick={() => applyAgent(agent.ops)}
|
||||
>
|
||||
+ {agent.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="portal-pipelines__library">
|
||||
{(Object.keys(PICKER_OPS) as OpKind[]).map((kind) => (
|
||||
<div key={kind} className="portal-pipelines__library-group">
|
||||
<div className="portal-pipelines__library-head">
|
||||
<span
|
||||
className="portal-pipelines__library-pip"
|
||||
style={{
|
||||
background: STAGE_COLOR_VAR[OP_KIND_ACCENT[kind]],
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
{t(`pipelines.composer.opKind.${OP_KIND_LABEL_KEY[kind]}`)}
|
||||
</div>
|
||||
<div className="portal-pipelines__library-chips">
|
||||
{PICKER_OPS[kind].map((op) => {
|
||||
const on = selectedOps.includes(op.id);
|
||||
return (
|
||||
<Chip
|
||||
key={op.id}
|
||||
tone={on ? OP_KIND_ACCENT[kind] : "neutral"}
|
||||
size="sm"
|
||||
onClick={() => toggleOp(op.id)}
|
||||
>
|
||||
{on ? "✓ " : ""}
|
||||
{op.label}
|
||||
</Chip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="portal-pipelines__composer-body">
|
||||
<span className="portal-pipelines__chain-label">
|
||||
{t("pipelines.composer.destination")}
|
||||
</span>
|
||||
<div className="portal-pipelines__composer-grid">
|
||||
{DESTINATION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
className={
|
||||
"portal-pipelines__option" +
|
||||
(destination === opt.id ? " is-selected" : "")
|
||||
}
|
||||
onClick={() => setDestination(opt.id)}
|
||||
>
|
||||
<span className="portal-pipelines__option-label">
|
||||
{opt.label}
|
||||
</span>
|
||||
<span className="portal-pipelines__option-desc">
|
||||
{opt.desc}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<span className="portal-pipelines__chain-label">
|
||||
{t("pipelines.composer.alerts")}
|
||||
</span>
|
||||
<div className="portal-pipelines__alerts">
|
||||
<label className="portal-pipelines__alert">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notifyEmail}
|
||||
onChange={(e) => setNotifyEmail(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<strong>{t("pipelines.composer.alert.email.title")}</strong>
|
||||
<span>{t("pipelines.composer.alert.email.desc")}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="portal-pipelines__alert">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notifyWebhook}
|
||||
onChange={(e) => setNotifyWebhook(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<strong>{t("pipelines.composer.alert.webhook.title")}</strong>
|
||||
<span>{t("pipelines.composer.alert.webhook.desc")}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="portal-pipelines__alert">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reviewQueue}
|
||||
onChange={(e) => setReviewQueue(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<strong>{t("pipelines.composer.alert.review.title")}</strong>
|
||||
<span>{t("pipelines.composer.alert.review.desc")}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && <Banner tone="danger" description={error} />}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { PipelineDetail } from "@portal/components/pipelines/PipelineDetail";
|
||||
import {
|
||||
DEGRADED_PIPELINE,
|
||||
HEALTHY_PIPELINE,
|
||||
} from "@portal/components/pipelines/storyFixtures";
|
||||
|
||||
const meta: Meta<typeof PipelineDetail> = {
|
||||
title: "Portal/Pipelines/PipelineDetail",
|
||||
component: PipelineDetail,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "32rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PipelineDetail>;
|
||||
|
||||
/** Clean golden set, no drift. */
|
||||
export const Healthy: Story = {
|
||||
args: { pipeline: HEALTHY_PIPELINE },
|
||||
};
|
||||
|
||||
/** Failing golden cases plus warning- and info-severity schema drift. */
|
||||
export const DegradedWithDrift: Story = {
|
||||
args: { pipeline: DEGRADED_PIPELINE },
|
||||
};
|
||||
@@ -1,183 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Chip,
|
||||
EmptyState,
|
||||
ProgressBar,
|
||||
StatTile,
|
||||
StatusBadge,
|
||||
} from "@shared/components";
|
||||
import type { Pipeline, SchemaDrift } from "@portal/api/pipelines";
|
||||
import {
|
||||
STAGE_ACCENT,
|
||||
STAGE_COLOR_VAR,
|
||||
} from "@portal/components/pipelines/stageAccent";
|
||||
import { compact, pct } from "@portal/components/pipelines/format";
|
||||
|
||||
function DriftRow({ drift }: { drift: SchemaDrift }) {
|
||||
const { t } = useTranslation();
|
||||
const confDelta =
|
||||
(drift.confidenceDelta > 0 ? "+" : "") + drift.confidenceDelta.toFixed(2);
|
||||
return (
|
||||
<li className="portal-pipelines__drift">
|
||||
<span
|
||||
className="portal-pipelines__drift-dot"
|
||||
style={{
|
||||
background:
|
||||
drift.severity === "warning"
|
||||
? "var(--color-amber)"
|
||||
: "var(--color-blue)",
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="portal-pipelines__drift-text">
|
||||
<code className="portal-pipelines__drift-field">{drift.field}</code>
|
||||
<span className="portal-pipelines__drift-note">{drift.note}</span>
|
||||
</div>
|
||||
<div className="portal-pipelines__drift-meta">
|
||||
<span>
|
||||
{t("pipelines.detail.drift.confidence", { delta: confDelta })}
|
||||
</span>
|
||||
<span>
|
||||
{t("pipelines.detail.drift.docs", { count: drift.affectedDocs })}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PipelineDetailProps {
|
||||
pipeline: Pipeline;
|
||||
}
|
||||
|
||||
/** Drawer body: 24h metrics, the five stages, golden-set health, and schema drift. */
|
||||
export function PipelineDetail({ pipeline }: PipelineDetailProps) {
|
||||
const { t } = useTranslation();
|
||||
const m = pipeline.metrics;
|
||||
const goldenRatio = pipeline.golden.total
|
||||
? pipeline.golden.passing / pipeline.golden.total
|
||||
: 0;
|
||||
const goldenClean = pipeline.golden.passing === pipeline.golden.total;
|
||||
|
||||
return (
|
||||
<div className="portal-pipelines__detail">
|
||||
<section className="portal-pipelines__detail-metrics">
|
||||
<StatTile
|
||||
label={t("pipelines.metrics.docs24h")}
|
||||
value={compact(m.docs24h)}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("pipelines.metrics.throughput")}
|
||||
value={`${m.throughputPerMin}/min`}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("pipelines.metrics.errorRate")}
|
||||
value={pct(m.errorRate, 2)}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("pipelines.metrics.p95Latency")}
|
||||
value={`${m.p95LatencyMs} ms`}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("pipelines.metrics.uptime")}
|
||||
value={pct(m.uptime, 2)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="portal-pipelines__detail-section">
|
||||
<h3 className="portal-pipelines__detail-h">
|
||||
{t("pipelines.detail.stages.heading")}
|
||||
</h3>
|
||||
<p className="portal-pipelines__detail-sub">
|
||||
{t("pipelines.detail.stages.description", {
|
||||
source: pipeline.source,
|
||||
destination: pipeline.destination,
|
||||
})}
|
||||
</p>
|
||||
<div className="portal-pipelines__stages">
|
||||
{pipeline.stages.map((stage) => {
|
||||
const accent = STAGE_ACCENT[stage.key];
|
||||
return (
|
||||
<div key={stage.key} className="portal-pipelines__stage">
|
||||
<div className="portal-pipelines__stage-head">
|
||||
<span
|
||||
className="portal-pipelines__stage-pip"
|
||||
style={{ background: STAGE_COLOR_VAR[accent] }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="portal-pipelines__stage-name">
|
||||
{stage.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="portal-pipelines__stage-chips">
|
||||
{stage.ops.length === 0 ? (
|
||||
<span className="portal-pipelines__stage-empty">
|
||||
{t("pipelines.detail.stages.noOps")}
|
||||
</span>
|
||||
) : (
|
||||
stage.ops.map((op) => (
|
||||
<Chip key={op} tone={accent} size="sm">
|
||||
{op}
|
||||
</Chip>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="portal-pipelines__detail-section">
|
||||
<h3 className="portal-pipelines__detail-h">
|
||||
{t("pipelines.detail.golden.heading")}
|
||||
</h3>
|
||||
<div className="portal-pipelines__golden">
|
||||
<div className="portal-pipelines__golden-head">
|
||||
<StatusBadge tone={goldenClean ? "success" : "warning"} size="sm">
|
||||
{t("pipelines.detail.golden.passing", {
|
||||
passing: pipeline.golden.passing,
|
||||
total: pipeline.golden.total,
|
||||
})}
|
||||
</StatusBadge>
|
||||
<span className="portal-pipelines__golden-when">
|
||||
{t("pipelines.detail.golden.lastRun", {
|
||||
lastRun: pipeline.golden.lastRun,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={goldenRatio}
|
||||
color={
|
||||
goldenClean
|
||||
? "var(--color-green)"
|
||||
: "linear-gradient(90deg, var(--color-amber), color-mix(in srgb, var(--color-amber) 70%, white))"
|
||||
}
|
||||
label={t("pipelines.detail.golden.barLabel", {
|
||||
passing: pipeline.golden.passing,
|
||||
total: pipeline.golden.total,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="portal-pipelines__detail-section">
|
||||
<h3 className="portal-pipelines__detail-h">
|
||||
{t("pipelines.detail.drift.heading")}
|
||||
</h3>
|
||||
{pipeline.drift.length === 0 ? (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={t("pipelines.detail.drift.empty.title")}
|
||||
description={t("pipelines.detail.drift.empty.description")}
|
||||
/>
|
||||
) : (
|
||||
<ul className="portal-pipelines__drift-list">
|
||||
{pipeline.drift.map((d) => (
|
||||
<DriftRow key={d.field} drift={d} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button, Chip } from "@shared/components";
|
||||
import { errorMessage } from "@portal/api/http";
|
||||
import {
|
||||
fetchRun,
|
||||
triggerPipeline,
|
||||
type PipelineView,
|
||||
type PolicyRunView,
|
||||
} from "@portal/api/pipelines";
|
||||
import { humanizeOperation } from "@portal/components/pipelines/pipelineOperations";
|
||||
import "@portal/views/Pipelines.css";
|
||||
|
||||
const TERMINAL_STATUSES = new Set(["COMPLETED", "FAILED", "CANCELLED"]);
|
||||
const POLL_INTERVAL_MS = 1500;
|
||||
const POLL_ATTEMPTS = 60;
|
||||
|
||||
type RunResult = { tone: "success" | "danger" | "info"; text: string };
|
||||
|
||||
interface PipelineDetailCardProps {
|
||||
pipeline: PipelineView;
|
||||
onClose: () => void;
|
||||
onEdit: (pipeline: PipelineView) => void;
|
||||
onTogglePause: (pipeline: PipelineView) => void;
|
||||
onDelete: (pipeline: PipelineView) => void;
|
||||
/** Disables the actions while a page-level mutation is in flight. */
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/** Expanded detail for the selected pipeline row, with run/edit/pause/delete actions. */
|
||||
export function PipelineDetailCard({
|
||||
pipeline,
|
||||
onClose,
|
||||
onEdit,
|
||||
onTogglePause,
|
||||
onDelete,
|
||||
busy = false,
|
||||
}: PipelineDetailCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const paused = pipeline.status === "paused";
|
||||
|
||||
const [running, setRunning] = useState(false);
|
||||
const [runResult, setRunResult] = useState<RunResult | null>(null);
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Poll a run until it reaches a terminal state (or we give up), so a failure
|
||||
// during execution surfaces with its error message rather than silently.
|
||||
async function awaitRun(runId: string): Promise<PolicyRunView | null> {
|
||||
for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) {
|
||||
if (!mounted.current) return null;
|
||||
const view = await fetchRun(runId);
|
||||
if (TERMINAL_STATUSES.has(view.status)) return view;
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleRun() {
|
||||
if (running || busy) return;
|
||||
setRunning(true);
|
||||
setRunResult(null);
|
||||
try {
|
||||
const runIds = await triggerPipeline(pipeline.id);
|
||||
if (runIds.length === 0) {
|
||||
if (mounted.current)
|
||||
setRunResult({ tone: "info", text: t("pipelines.run.empty") });
|
||||
return;
|
||||
}
|
||||
const finals = await Promise.all(runIds.map((id) => awaitRun(id)));
|
||||
if (!mounted.current) return;
|
||||
const failed = finals.find((r) => r?.status === "FAILED");
|
||||
if (failed) {
|
||||
setRunResult({
|
||||
tone: "danger",
|
||||
text: t("pipelines.run.failed", { error: failed.error ?? "" }),
|
||||
});
|
||||
} else if (finals.every((r) => r?.status === "COMPLETED")) {
|
||||
setRunResult({
|
||||
tone: "success",
|
||||
text: t("pipelines.run.completed", { count: finals.length }),
|
||||
});
|
||||
} else {
|
||||
// Still running when we stopped polling, or cancelled.
|
||||
setRunResult({ tone: "info", text: t("pipelines.run.running") });
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted.current)
|
||||
setRunResult({ tone: "danger", text: errorMessage(e) });
|
||||
} finally {
|
||||
if (mounted.current) setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="portal-pipelines__expanded">
|
||||
<header className="portal-pipelines__expanded-head">
|
||||
<span className="portal-pipelines__pipe-dot" aria-hidden>
|
||||
⛓
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="portal-pipelines__expanded-title">{pipeline.name}</h2>
|
||||
<span className="portal-pipelines__expanded-sub">
|
||||
{t("pipelines.detail.subtitle", {
|
||||
trigger: t(`pipelines.trigger.${pipeline.trigger}`, {
|
||||
defaultValue: pipeline.trigger,
|
||||
}),
|
||||
status: t(`pipelines.status.${pipeline.status}`),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="portal-pipelines__expanded-close"
|
||||
onClick={onClose}
|
||||
aria-label={t("pipelines.detail.closeAriaLabel")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="portal-pipelines__detail">
|
||||
<div className="portal-pipelines__detail-section">
|
||||
<span className="portal-pipelines__detail-heading">
|
||||
{t("pipelines.detail.steps")}
|
||||
</span>
|
||||
{pipeline.steps.length === 0 ? (
|
||||
<p className="portal-pipelines__muted">
|
||||
{t("pipelines.detail.noSteps")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="portal-pipelines__chips">
|
||||
{pipeline.steps.map((step, i) => (
|
||||
<Chip key={`${step}-${i}`} tone="blue" size="sm">
|
||||
{`${i + 1}. ${humanizeOperation(step)}`}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="portal-pipelines__detail-section">
|
||||
<span className="portal-pipelines__detail-heading">
|
||||
{t("pipelines.detail.sources")}
|
||||
</span>
|
||||
{pipeline.sources.length === 0 ? (
|
||||
<p className="portal-pipelines__muted">
|
||||
{t("pipelines.detail.noSources")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="portal-pipelines__chips">
|
||||
{pipeline.sources.map((source) => (
|
||||
<Chip key={source.id} tone="neutral" size="sm">
|
||||
{source.name}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="portal-pipelines__detail-section">
|
||||
<span className="portal-pipelines__detail-heading">
|
||||
{t("pipelines.detail.output")}
|
||||
</span>
|
||||
<Chip tone="purple" size="sm">
|
||||
{t(`pipelines.output.${pipeline.output}`, {
|
||||
defaultValue: pipeline.output,
|
||||
})}
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{runResult && (
|
||||
<Banner tone={runResult.tone} description={runResult.text} />
|
||||
)}
|
||||
|
||||
<div className="portal-pipelines__detail-actions">
|
||||
<Button loading={running} disabled={busy} onClick={handleRun}>
|
||||
{t("pipelines.detail.run")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy || running}
|
||||
onClick={() => onEdit(pipeline)}
|
||||
>
|
||||
{t("pipelines.detail.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy || running}
|
||||
onClick={() => onTogglePause(pipeline)}
|
||||
>
|
||||
{paused ? t("pipelines.detail.resume") : t("pipelines.detail.pause")}
|
||||
</Button>
|
||||
<Button
|
||||
accent="red"
|
||||
variant="outline"
|
||||
disabled={busy || running}
|
||||
onClick={() => onDelete(pipeline)}
|
||||
>
|
||||
{t("pipelines.detail.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { PipelineListSkeleton } from "@portal/components/pipelines/PipelineListSkeleton";
|
||||
|
||||
const meta: Meta<typeof PipelineListSkeleton> = {
|
||||
title: "Portal/Pipelines/PipelineListSkeleton",
|
||||
component: PipelineListSkeleton,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "52rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PipelineListSkeleton>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Card, Skeleton } from "@shared/components";
|
||||
|
||||
/** Placeholder fleet while the deployed pipelines load. */
|
||||
export function PipelineListSkeleton() {
|
||||
return (
|
||||
<div className="portal-pipelines__list" aria-hidden>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i} padding="loose" className="portal-pipelines__card">
|
||||
<div className="portal-pipelines__card-head">
|
||||
<Skeleton width="11rem" height="1.1rem" />
|
||||
<Skeleton width="5rem" height="1.1rem" />
|
||||
</div>
|
||||
<Skeleton width="80%" height="0.75rem" />
|
||||
<Skeleton height="3rem" />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Chip,
|
||||
StatusBadge,
|
||||
type StatusTone,
|
||||
Table,
|
||||
type TableColumn,
|
||||
} from "@shared/components";
|
||||
import type { PipelineStatus, PipelineView } from "@portal/api/pipelines";
|
||||
|
||||
const STATUS_TONE: Record<PipelineStatus, StatusTone> = {
|
||||
active: "success",
|
||||
paused: "neutral",
|
||||
};
|
||||
|
||||
interface PipelinesTableProps {
|
||||
pipelines: PipelineView[];
|
||||
/** Id of the row whose detail panel is open, drives the caret state. */
|
||||
expandedId: string | null;
|
||||
onRowClick: (pipeline: PipelineView) => void;
|
||||
}
|
||||
|
||||
export function PipelinesTable({
|
||||
pipelines,
|
||||
expandedId,
|
||||
onRowClick,
|
||||
}: PipelinesTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const columns = useMemo<TableColumn<PipelineView>[]>(
|
||||
() => [
|
||||
{
|
||||
key: "name",
|
||||
header: t("pipelines.table.name"),
|
||||
render: (p) => (
|
||||
<div className="portal-pipelines__name-cell">
|
||||
<span className="portal-pipelines__pipe-dot" aria-hidden>
|
||||
⛓
|
||||
</span>
|
||||
<div className="portal-pipelines__name-text">
|
||||
<strong>{p.name}</strong>
|
||||
<Chip tone="neutral" size="sm">
|
||||
{t(`pipelines.trigger.${p.trigger}`, {
|
||||
defaultValue: p.trigger,
|
||||
})}
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: t("pipelines.table.status"),
|
||||
render: (p) => (
|
||||
<StatusBadge
|
||||
tone={STATUS_TONE[p.status]}
|
||||
size="sm"
|
||||
pulse={p.status === "active"}
|
||||
>
|
||||
{t(`pipelines.status.${p.status}`)}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "steps",
|
||||
header: t("pipelines.table.steps"),
|
||||
align: "right",
|
||||
render: (p) => (
|
||||
<span
|
||||
className={
|
||||
p.steps.length === 0 ? "portal-pipelines__muted" : undefined
|
||||
}
|
||||
>
|
||||
{p.steps.length}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sources",
|
||||
header: t("pipelines.table.sources"),
|
||||
align: "right",
|
||||
render: (p) => (
|
||||
<span
|
||||
className={
|
||||
p.sources.length === 0 ? "portal-pipelines__muted" : undefined
|
||||
}
|
||||
>
|
||||
{p.sources.length}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "expand",
|
||||
header: "",
|
||||
align: "right",
|
||||
width: "2.5rem",
|
||||
render: (p) => (
|
||||
<span
|
||||
className={
|
||||
"portal-pipelines__caret" +
|
||||
(expandedId === p.id ? " is-open" : "")
|
||||
}
|
||||
aria-hidden
|
||||
>
|
||||
▸
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[expandedId, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Table<PipelineView>
|
||||
className="portal-pipelines__table"
|
||||
columns={columns}
|
||||
rows={pipelines}
|
||||
rowKey={(p) => p.id}
|
||||
onRowClick={onRowClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { PromotedPipelines } from "@portal/components/pipelines/PromotedPipelines";
|
||||
import { PROMOTED_PIPELINES } from "@portal/components/pipelines/storyFixtures";
|
||||
import "@portal/views/Pipelines.css";
|
||||
|
||||
const meta: Meta<typeof PromotedPipelines> = {
|
||||
title: "Portal/Pipelines/PromotedPipelines",
|
||||
component: PromotedPipelines,
|
||||
parameters: { layout: "padded" },
|
||||
args: { promoted: PROMOTED_PIPELINES },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "72rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PromotedPipelines>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: { promoted: [] },
|
||||
};
|
||||
@@ -1,139 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Button,
|
||||
StatusBadge,
|
||||
type StatusTone,
|
||||
Table,
|
||||
type TableColumn,
|
||||
} from "@shared/components";
|
||||
import {
|
||||
promoteToPolicy,
|
||||
type PromotedPipeline,
|
||||
type PromotedStatus,
|
||||
} from "@portal/api/pipelines";
|
||||
|
||||
const STATUS_TONE: Record<PromotedStatus, StatusTone> = {
|
||||
deployed: "success",
|
||||
staged: "info",
|
||||
review: "warning",
|
||||
};
|
||||
|
||||
/** Translation key suffixes for each promoted-pipeline status badge. */
|
||||
const STATUS_LABEL_KEY: Record<PromotedStatus, string> = {
|
||||
deployed: "deployed",
|
||||
staged: "staged",
|
||||
review: "review",
|
||||
};
|
||||
|
||||
/** Per-row promote-to-policy lifecycle, kept local until a backend exists. */
|
||||
type PromoteState = "idle" | "pending" | "done";
|
||||
|
||||
interface PromotedPipelinesProps {
|
||||
promoted: PromotedPipeline[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Flows that started as Editor watch-folder automations and were promoted into
|
||||
* the portal. Each keeps a pointer back to the watch folder it grew from, and
|
||||
* offers a one-click path to lift its rules into a fleet-wide org policy.
|
||||
*/
|
||||
export function PromotedPipelines({ promoted }: PromotedPipelinesProps) {
|
||||
const { t } = useTranslation();
|
||||
// Promote submits have no backend yet, so reflect acceptance per row locally.
|
||||
const [promoteState, setPromoteState] = useState<
|
||||
Record<string, PromoteState>
|
||||
>({});
|
||||
|
||||
const onPromote = async (p: PromotedPipeline) => {
|
||||
setPromoteState((s) => ({ ...s, [p.id]: "pending" }));
|
||||
try {
|
||||
// TODO(backend): POST /v1/pipelines/{id}/promote-to-policy — stubbed,
|
||||
// resolves against the mock handler; treat success as accepted.
|
||||
await promoteToPolicy(p.id);
|
||||
setPromoteState((s) => ({ ...s, [p.id]: "done" }));
|
||||
} catch {
|
||||
setPromoteState((s) => ({ ...s, [p.id]: "idle" }));
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo<TableColumn<PromotedPipeline>[]>(
|
||||
() => [
|
||||
{
|
||||
key: "name",
|
||||
header: t("pipelines.table.header.name"),
|
||||
render: (p) => (
|
||||
<div className="portal-pipelines__promoted-name">
|
||||
<strong>{p.name}</strong>
|
||||
<span className="portal-pipelines__promoted-when">
|
||||
{p.promotedAt}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "docType",
|
||||
header: t("pipelines.promoted.table.sourceDocType"),
|
||||
render: (p) => (
|
||||
<span className="portal-pipelines__promoted-muted">
|
||||
{p.sourceDocType}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "watchFolder",
|
||||
header: t("pipelines.promoted.table.watchFolder"),
|
||||
render: (p) => (
|
||||
<code className="portal-pipelines__promoted-folder">
|
||||
{p.watchFolder}
|
||||
</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: t("pipelines.promoted.table.status"),
|
||||
render: (p) => (
|
||||
<StatusBadge tone={STATUS_TONE[p.status]} size="sm">
|
||||
{t(`pipelines.promoted.status.${STATUS_LABEL_KEY[p.status]}`)}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "promote",
|
||||
header: "",
|
||||
align: "right",
|
||||
width: "11rem",
|
||||
render: (p) => {
|
||||
const state = promoteState[p.id] ?? "idle";
|
||||
if (state === "done") {
|
||||
return (
|
||||
<StatusBadge tone="success" size="sm">
|
||||
{t("pipelines.promoted.policyCreated")}
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={state === "pending"}
|
||||
onClick={() => onPromote(p)}
|
||||
>
|
||||
{t("pipelines.promoted.promoteToPolicy")}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[promoteState, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Table<PromotedPipeline>
|
||||
className="portal-pipelines__promoted"
|
||||
columns={columns}
|
||||
rows={promoted}
|
||||
rowKey={(p) => p.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { GoldenSet } from "@portal/api/pipelines";
|
||||
import type { StatusTone } from "@shared/components";
|
||||
|
||||
/** Fraction → percentage string (0.004 → "0.40%"). */
|
||||
export const pct = (n: number, digits = 1) => `${(n * 100).toFixed(digits)}%`;
|
||||
|
||||
/** Compact count for dense metric tiles (28941 → "28.9K"). */
|
||||
export const compact = (n: number) =>
|
||||
new Intl.NumberFormat(undefined, {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(n);
|
||||
|
||||
/**
|
||||
* Golden-set reliability tone, judged against the pipeline's own pass-rate
|
||||
* bound. At/above bound is green; a small slip under is amber; a clear miss is
|
||||
* danger — so a row's reliability reads from colour alone.
|
||||
*/
|
||||
export function goldenTone(golden: GoldenSet): StatusTone {
|
||||
if (golden.total === 0) return "neutral";
|
||||
const rate = golden.passing / golden.total;
|
||||
if (rate >= golden.threshold) return "success";
|
||||
// Within five points of the bound is a warning; further off is a hard miss.
|
||||
if (rate >= golden.threshold - 0.05) return "warning";
|
||||
return "danger";
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* The operation catalogue the composer builds a pipeline from. Each entry's
|
||||
* `operation` is a real Stirling endpoint path (the backend's PipelineStep
|
||||
* contract) with sensible default `parameters`, so a composed pipeline is
|
||||
* runnable as-is. Per-operation parameter editing is intentionally out of scope
|
||||
* for this version: the composer chains operations with their defaults. Labels
|
||||
* are derived from the path, so adding an operation needs no translation work.
|
||||
*/
|
||||
|
||||
export interface PipelineOperationDef {
|
||||
/** Stirling endpoint path, e.g. "/api/v1/misc/compress-pdf". */
|
||||
operation: string;
|
||||
/** Scalar form fields the endpoint accepts; defaults that keep the step valid. */
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const PIPELINE_OPERATIONS: PipelineOperationDef[] = [
|
||||
{ operation: "/api/v1/misc/ocr-pdf", parameters: {} },
|
||||
{ operation: "/api/v1/misc/compress-pdf", parameters: {} },
|
||||
{ operation: "/api/v1/misc/flatten", parameters: {} },
|
||||
{ operation: "/api/v1/misc/repair", parameters: {} },
|
||||
{
|
||||
operation: "/api/v1/security/auto-redact",
|
||||
parameters: { mode: "automatic", convertPDFToImage: true },
|
||||
},
|
||||
{
|
||||
operation: "/api/v1/security/sanitize-pdf",
|
||||
parameters: { removeJavaScript: true },
|
||||
},
|
||||
{ operation: "/api/v1/security/add-watermark", parameters: {} },
|
||||
{ operation: "/api/v1/security/add-password", parameters: {} },
|
||||
{ operation: "/api/v1/security/remove-password", parameters: {} },
|
||||
{ operation: "/api/v1/general/merge-pdfs", parameters: {} },
|
||||
{ operation: "/api/v1/misc/add-stamp", parameters: {} },
|
||||
{ operation: "/api/v1/misc/add-page-numbers", parameters: {} },
|
||||
];
|
||||
|
||||
/**
|
||||
* Turn an endpoint path into a display label: take the last segment, drop the
|
||||
* "pdf"/"pdfs" filler words, and title-case the rest.
|
||||
* "/api/v1/misc/compress-pdf" → "Compress"; "/api/v1/security/auto-redact" →
|
||||
* "Auto Redact". Works for any operation, including ones loaded from the backend
|
||||
* that aren't in the catalogue above.
|
||||
*/
|
||||
export function humanizeOperation(path: string): string {
|
||||
const last = path.split("/").filter(Boolean).pop() ?? path;
|
||||
const words = last.split("-").filter((w) => w !== "pdf" && w !== "pdfs");
|
||||
const base = (words.length > 0 ? words : [last]).join(" ");
|
||||
return base.replace(/\b\w/g, (c) => c.toUpperCase()).trim();
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { StageKey } from "@portal/api/pipelines";
|
||||
import type { OpKind } from "@shared/data/ops";
|
||||
|
||||
/**
|
||||
* Fixed accent per stage so the chip row reads the same across every pipeline.
|
||||
* Each value is also a valid {@link import("@shared/components").ChipTone}, so
|
||||
* it doubles as the chip tone wherever a stage colour is rendered.
|
||||
*/
|
||||
export type StageAccent = "green" | "blue" | "amber" | "red" | "purple";
|
||||
|
||||
export const STAGE_ACCENT: Record<StageKey, StageAccent> = {
|
||||
ingest: "green",
|
||||
validate: "blue",
|
||||
modify: "amber",
|
||||
secure: "red",
|
||||
route: "purple",
|
||||
};
|
||||
|
||||
/** Op-kind accent mirrors the stage palette; alerts share the route colour. */
|
||||
export const OP_KIND_ACCENT: Record<OpKind, StageAccent> = {
|
||||
ingest: "green",
|
||||
validate: "blue",
|
||||
modify: "amber",
|
||||
secure: "red",
|
||||
store: "purple",
|
||||
alert: "purple",
|
||||
};
|
||||
|
||||
export const STAGE_COLOR_VAR: Record<StageAccent, string> = {
|
||||
green: "var(--color-green)",
|
||||
blue: "var(--color-blue)",
|
||||
amber: "var(--color-amber)",
|
||||
red: "var(--color-red)",
|
||||
purple: "var(--color-purple)",
|
||||
};
|
||||
@@ -1,102 +0,0 @@
|
||||
import type { Pipeline, PromotedPipeline } from "@portal/api/pipelines";
|
||||
|
||||
/** Sample pipelines shared by the Pipelines component stories. */
|
||||
|
||||
export const HEALTHY_PIPELINE: Pipeline = {
|
||||
id: "pl-invoice-ap",
|
||||
name: "Invoice → AP",
|
||||
blurb: "Invoice extraction → three-way match → Postgres",
|
||||
status: "healthy",
|
||||
source: "S3 bucket watch",
|
||||
destination: "Database",
|
||||
version: "v2.8.0",
|
||||
regions: ["us-east-1", "eu-west-1", "ap-southeast-1"],
|
||||
metrics: {
|
||||
docs24h: 53120,
|
||||
throughputPerMin: 41,
|
||||
errorRate: 0.006,
|
||||
p95LatencyMs: 358,
|
||||
uptime: 0.9997,
|
||||
},
|
||||
stages: [
|
||||
{ key: "ingest", label: "Ingest", ops: ["Parse", "Classify", "Extract"] },
|
||||
{
|
||||
key: "validate",
|
||||
label: "Validate",
|
||||
ops: ["Schema validate", "Confidence bounds"],
|
||||
},
|
||||
{ key: "modify", label: "Modify", ops: ["PDF → CSV"] },
|
||||
{
|
||||
key: "secure",
|
||||
label: "Secure",
|
||||
ops: ["Redact PII", "Encryption at rest"],
|
||||
},
|
||||
{ key: "route", label: "Route / Store", ops: ["Primary store", "Notify"] },
|
||||
],
|
||||
golden: { passing: 42, total: 42, lastRun: "1h ago", threshold: 0.95 },
|
||||
drift: [],
|
||||
};
|
||||
|
||||
export const DEGRADED_PIPELINE: Pipeline = {
|
||||
...HEALTHY_PIPELINE,
|
||||
id: "pl-prior-auth",
|
||||
name: "Prior Auth Router",
|
||||
blurb: "Prior-authorization intake → medical-necessity gate → payer webhook",
|
||||
status: "degraded",
|
||||
source: "Inbound webhook",
|
||||
destination: "Outbound webhook",
|
||||
version: "v3.1.0",
|
||||
regions: ["us-east-1"],
|
||||
metrics: {
|
||||
docs24h: 11204,
|
||||
throughputPerMin: 9,
|
||||
errorRate: 0.031,
|
||||
p95LatencyMs: 740,
|
||||
uptime: 0.9962,
|
||||
},
|
||||
golden: { passing: 24, total: 28, lastRun: "47m ago", threshold: 0.9 },
|
||||
drift: [
|
||||
{
|
||||
field: "procedure_codes",
|
||||
note: "New CPT modifier suffix not seen in prior examples",
|
||||
confidenceDelta: -0.07,
|
||||
severity: "warning",
|
||||
affectedDocs: 18,
|
||||
},
|
||||
{
|
||||
field: "payer",
|
||||
note: "Two payers now emit a merged-entity name",
|
||||
confidenceDelta: -0.03,
|
||||
severity: "info",
|
||||
affectedDocs: 6,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Sample watch-folder-promoted flows for the PromotedPipelines stories. */
|
||||
export const PROMOTED_PIPELINES: PromotedPipeline[] = [
|
||||
{
|
||||
id: "pl-promo-statements",
|
||||
name: "Bank Statement Normalizer",
|
||||
sourceDocType: "Bank statement",
|
||||
watchFolder: "~/StirlingWatch/statements-in",
|
||||
status: "deployed",
|
||||
promotedAt: "promoted 3d ago",
|
||||
},
|
||||
{
|
||||
id: "pl-promo-receipts",
|
||||
name: "Receipt Splitter",
|
||||
sourceDocType: "Expense receipt",
|
||||
watchFolder: "~/StirlingWatch/receipts",
|
||||
status: "staged",
|
||||
promotedAt: "promoted 11h ago",
|
||||
},
|
||||
{
|
||||
id: "pl-promo-onboarding",
|
||||
name: "New-Hire Packet Sorter",
|
||||
sourceDocType: "Onboarding packet",
|
||||
watchFolder: "\\\\hr-share\\NewHireScans",
|
||||
status: "review",
|
||||
promotedAt: "promoted 2h ago",
|
||||
},
|
||||
];
|
||||
@@ -1,20 +1,212 @@
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import { pipelinesFor } from "@portal/mocks/pipelines";
|
||||
import type {
|
||||
PipelineKpi,
|
||||
PipelineStatus,
|
||||
PipelineView,
|
||||
PipelinesOverviewResponse,
|
||||
Policy,
|
||||
} from "@portal/api/pipelines";
|
||||
|
||||
/**
|
||||
* Stateful mock for the Pipelines surface so the portal works fully offline with
|
||||
* mocks on. Mirrors the real backend shape (`/api/v1/policies`, PolicyController +
|
||||
* PolicyOverviewService): the overview, create, edit, and delete mutate an
|
||||
* in-memory store of real-shaped policies. With mocks OFF these calls fall through
|
||||
* to the real backend instead.
|
||||
*
|
||||
* The user-facing Policies "catalogue" page also lives on `/api/v1/policies` (its
|
||||
* own handlers, a different response shape). These handlers are registered first
|
||||
* and DISCRIMINATE: anything that isn't a real-shaped pipeline (the catalogue
|
||||
* page's bodies carry a `categoryId`; its ids aren't in this store) is passed
|
||||
* through by returning nothing, so the catalogue handlers still serve it. That
|
||||
* keeps both surfaces working in mock mode without either clobbering the other.
|
||||
*/
|
||||
|
||||
/** Display names for the seeded sources, so the overview resolves ids to names. */
|
||||
const SOURCE_NAMES: Record<string, string> = {
|
||||
"src-claims": "Claims intake",
|
||||
"src-contracts": "Contracts drop",
|
||||
"src-archive": "Archive reprocess",
|
||||
};
|
||||
|
||||
interface StoredPolicy extends Policy {
|
||||
id: string;
|
||||
}
|
||||
|
||||
function seedPipelines(): StoredPolicy[] {
|
||||
return [
|
||||
{
|
||||
id: "plc-redaction",
|
||||
name: "Redaction sweep",
|
||||
owner: "security@acme.com",
|
||||
enabled: true,
|
||||
trigger: {
|
||||
type: "schedule",
|
||||
options: { schedule: { type: "every", count: 6, unit: "HOURS" } },
|
||||
},
|
||||
sourceIds: ["src-claims"],
|
||||
steps: [
|
||||
{
|
||||
operation: "/api/v1/security/auto-redact",
|
||||
parameters: { mode: "automatic", convertPDFToImage: true },
|
||||
},
|
||||
{ operation: "/api/v1/security/sanitize-pdf", parameters: {} },
|
||||
],
|
||||
output: { type: "inline", options: {} },
|
||||
},
|
||||
{
|
||||
id: "plc-archive",
|
||||
name: "Archive compressor",
|
||||
owner: "data-eng@acme.com",
|
||||
enabled: true,
|
||||
trigger: null,
|
||||
sourceIds: ["src-contracts", "src-archive"],
|
||||
steps: [{ operation: "/api/v1/misc/compress-pdf", parameters: {} }],
|
||||
output: { type: "folder", options: { directory: "/data/archive-out" } },
|
||||
},
|
||||
{
|
||||
id: "plc-onboarding",
|
||||
name: "Onboarding OCR (paused)",
|
||||
owner: "ops@acme.com",
|
||||
enabled: false,
|
||||
trigger: null,
|
||||
sourceIds: [],
|
||||
steps: [
|
||||
{ operation: "/api/v1/misc/ocr-pdf", parameters: {} },
|
||||
{ operation: "/api/v1/misc/flatten", parameters: {} },
|
||||
],
|
||||
output: { type: "inline", options: {} },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
let store: StoredPolicy[] = seedPipelines();
|
||||
|
||||
let idCounter = 0;
|
||||
function nextId(): string {
|
||||
idCounter += 1;
|
||||
return `plc_${Date.now().toString(36)}_${idCounter}`;
|
||||
}
|
||||
|
||||
function deriveStatus(policy: StoredPolicy): PipelineStatus {
|
||||
return policy.enabled ? "active" : "paused";
|
||||
}
|
||||
|
||||
function toView(policy: StoredPolicy): PipelineView {
|
||||
return {
|
||||
id: policy.id,
|
||||
name: policy.name,
|
||||
enabled: policy.enabled,
|
||||
status: deriveStatus(policy),
|
||||
trigger: policy.trigger?.type ?? "manual",
|
||||
sources: policy.sourceIds.map((id) => ({
|
||||
id,
|
||||
name: SOURCE_NAMES[id] ?? id,
|
||||
})),
|
||||
steps: policy.steps.map((s) => s.operation),
|
||||
output: policy.output?.type ?? "inline",
|
||||
owner: policy.owner ?? "you@acme.com",
|
||||
};
|
||||
}
|
||||
|
||||
function buildKpis(): PipelineKpi[] {
|
||||
const total = store.length;
|
||||
const active = store.filter((p) => p.enabled).length;
|
||||
return [
|
||||
{ value: total, description: "pipelines" },
|
||||
{ value: active, description: "running automatically" },
|
||||
{ value: total - active, description: "paused" },
|
||||
];
|
||||
}
|
||||
|
||||
function buildOverview(): PipelinesOverviewResponse {
|
||||
const pipelines = store
|
||||
.map(toView)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return { kpis: buildKpis(), pipelines };
|
||||
}
|
||||
|
||||
export const pipelinesHandlers = [
|
||||
http.get("/v1/pipelines", async ({ request }) => {
|
||||
http.get("/api/v1/policies/overview", async () => {
|
||||
await delay(120);
|
||||
const url = new URL(request.url);
|
||||
const tier = (url.searchParams.get("tier") ?? "pro") as Tier;
|
||||
return HttpResponse.json(pipelinesFor(tier));
|
||||
return HttpResponse.json(buildOverview());
|
||||
}),
|
||||
|
||||
// Accepts the promote-to-policy submit so the UI can resolve. The real
|
||||
// backend would create a policy from the pipeline's stages; here it just
|
||||
// acknowledges. See TODO(backend) on api/pipelines.ts promoteToPolicy.
|
||||
http.post("/v1/pipelines/:id/promote-to-policy", async () => {
|
||||
// Available triggers + their source-type compatibility. Registered before the
|
||||
// ":id" handler so "triggers" isn't matched as a policy id.
|
||||
http.get("/api/v1/policies/triggers", async () => {
|
||||
await delay(120);
|
||||
return HttpResponse.json({ ok: true });
|
||||
return HttpResponse.json([
|
||||
{ type: "schedule", requiresSource: false, supportedSourceTypes: [] },
|
||||
{
|
||||
type: "folder-watch",
|
||||
requiresSource: true,
|
||||
supportedSourceTypes: ["folder"],
|
||||
},
|
||||
]);
|
||||
}),
|
||||
|
||||
// Run status: the mock completes runs immediately, so polling resolves at once.
|
||||
http.get("/api/v1/policies/run/:runId", async ({ params }) => {
|
||||
await delay(120);
|
||||
return HttpResponse.json({
|
||||
runId: String(params.runId),
|
||||
policyId: null,
|
||||
status: "COMPLETED",
|
||||
currentStep: 1,
|
||||
stepCount: 1,
|
||||
error: null,
|
||||
errorCode: null,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}),
|
||||
|
||||
// Manual trigger: pretends to start one run and returns its id to poll.
|
||||
http.post("/api/v1/policies/:id/trigger", async ({ params }) => {
|
||||
if (!store.some((p) => p.id === params.id)) return undefined;
|
||||
await delay(120);
|
||||
return HttpResponse.json([`run_${Date.now().toString(36)}`]);
|
||||
}),
|
||||
|
||||
// Raw policy by id. Only our pipeline ids are served here; everything else falls
|
||||
// through to the catalogue page's handler.
|
||||
http.get("/api/v1/policies/:id", async ({ params }) => {
|
||||
const policy = store.find((p) => p.id === params.id);
|
||||
if (!policy) return undefined;
|
||||
await delay(120);
|
||||
return HttpResponse.json(policy);
|
||||
}),
|
||||
|
||||
// Create or update a pipeline. The catalogue page's bodies carry a `categoryId`;
|
||||
// those are passed through so its own handler stores them.
|
||||
http.post("/api/v1/policies", async ({ request }) => {
|
||||
// Clone before reading: a non-pipeline body falls through to the catalogue
|
||||
// page's handler, which needs to read the (still-unconsumed) body itself.
|
||||
const incoming = (await request.clone().json()) as Policy & {
|
||||
categoryId?: string;
|
||||
};
|
||||
if ("categoryId" in incoming) return undefined;
|
||||
await delay(120);
|
||||
const existing = incoming.id
|
||||
? store.find((p) => p.id === incoming.id)
|
||||
: undefined;
|
||||
const id = existing?.id ?? nextId();
|
||||
const saved: StoredPolicy = {
|
||||
...incoming,
|
||||
id,
|
||||
owner: existing?.owner ?? "you@acme.com",
|
||||
};
|
||||
store = existing
|
||||
? store.map((p) => (p.id === id ? saved : p))
|
||||
: [...store, saved];
|
||||
return HttpResponse.json(saved);
|
||||
}),
|
||||
|
||||
http.delete("/api/v1/policies/:id", async ({ params }) => {
|
||||
const id = String(params.id);
|
||||
if (!store.some((p) => p.id === id)) return undefined;
|
||||
await delay(120);
|
||||
store = store.filter((p) => p.id !== id);
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -1,415 +0,0 @@
|
||||
/**
|
||||
* Pipelines fixtures and the types api/pipelines.ts shares with them.
|
||||
* api/pipelines.ts imports the types; the MSW handlers in mocks/handlers/
|
||||
* serve the fixture data over the intercepted apiClient.local.json() calls. Components
|
||||
* never reach into this module directly.
|
||||
*
|
||||
* Fixtures are tier-shaped:
|
||||
* - free → empty (prompts "build your first pipeline")
|
||||
* - pro → a small deployed fleet
|
||||
* - enterprise → a larger fleet plus a shadow/comparative evals note
|
||||
*/
|
||||
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
/** A deployed pipeline's live health. */
|
||||
export type PipelineStatus = "healthy" | "degraded";
|
||||
|
||||
/** 24-hour rollup shown on the pipeline row. */
|
||||
export interface PipelineMetrics {
|
||||
/** Docs processed in the trailing 24h. */
|
||||
docs24h: number;
|
||||
/** Sustained throughput, docs/min. */
|
||||
throughputPerMin: number;
|
||||
/** Error rate as a fraction (0.004 = 0.4%). */
|
||||
errorRate: number;
|
||||
/** P95 stage-to-store latency, ms. */
|
||||
p95LatencyMs: number;
|
||||
/** Uptime over the trailing 24h, as a fraction. */
|
||||
uptime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The five silent stages every pipeline passes a document through. The accent
|
||||
* is fixed per stage so the chip row reads the same across every pipeline:
|
||||
* Ingest=green, Validate=blue, Modify=amber, Secure=red, Route/Store=purple.
|
||||
*/
|
||||
export type StageKey = "ingest" | "validate" | "modify" | "secure" | "route";
|
||||
|
||||
export interface StageSummary {
|
||||
key: StageKey;
|
||||
label: string;
|
||||
/** Op labels active in this stage for this pipeline. */
|
||||
ops: string[];
|
||||
}
|
||||
|
||||
/** Golden-set validation rollup. */
|
||||
export interface GoldenSet {
|
||||
passing: number;
|
||||
total: number;
|
||||
/** Last time the set was run. */
|
||||
lastRun: string;
|
||||
/**
|
||||
* Minimum pass rate (fraction) this pipeline must hold to be considered
|
||||
* reliable. A pipeline below its own bound is amber/red at a glance — the
|
||||
* bound is per-pipeline because a clause-risk pipeline tolerates less slack
|
||||
* than a high-volume extraction one.
|
||||
*/
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
/** A single field whose shape has drifted from the inferred schema. */
|
||||
export interface SchemaDrift {
|
||||
field: string;
|
||||
/** Human summary of what changed. */
|
||||
note: string;
|
||||
/** Confidence delta since the last known-good shape (negative = worse). */
|
||||
confidenceDelta: number;
|
||||
severity: "info" | "warning";
|
||||
/** Share of docs in the window that exhibited the drift. */
|
||||
affectedDocs: number;
|
||||
}
|
||||
|
||||
export interface Pipeline {
|
||||
id: string;
|
||||
name: string;
|
||||
/** What the pipeline does, one line. */
|
||||
blurb: string;
|
||||
status: PipelineStatus;
|
||||
/** Source rail label (from SOURCE_OPTIONS). */
|
||||
source: string;
|
||||
/** Destination rail label (from DESTINATION_OPTIONS). */
|
||||
destination: string;
|
||||
/** Deployed version tag. */
|
||||
version: string;
|
||||
/** Regions the pipeline runs in. */
|
||||
regions: string[];
|
||||
metrics: PipelineMetrics;
|
||||
stages: StageSummary[];
|
||||
golden: GoldenSet;
|
||||
drift: SchemaDrift[];
|
||||
}
|
||||
|
||||
const STAGE_LABEL: Record<StageKey, string> = {
|
||||
ingest: "Ingest",
|
||||
validate: "Validate",
|
||||
modify: "Modify",
|
||||
secure: "Secure",
|
||||
route: "Route / Store",
|
||||
};
|
||||
|
||||
/** Build a five-stage summary from the per-stage op-label lists. */
|
||||
function stages(
|
||||
ingest: string[],
|
||||
validate: string[],
|
||||
modify: string[],
|
||||
secure: string[],
|
||||
route: string[],
|
||||
): StageSummary[] {
|
||||
const byKey: Record<StageKey, string[]> = {
|
||||
ingest,
|
||||
validate,
|
||||
modify,
|
||||
secure,
|
||||
route,
|
||||
};
|
||||
return (Object.keys(byKey) as StageKey[]).map((key) => ({
|
||||
key,
|
||||
label: STAGE_LABEL[key],
|
||||
ops: byKey[key],
|
||||
}));
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Tier fixtures */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const COI_COMPLIANCE: Pipeline = {
|
||||
id: "pl-coi",
|
||||
name: "COI Compliance",
|
||||
blurb: "Certificate-of-insurance intake → coverage-gap check → vault",
|
||||
status: "healthy",
|
||||
source: "Email intake",
|
||||
destination: "Stirling vault",
|
||||
version: "v3.4.1",
|
||||
regions: ["us-east-1", "eu-west-1"],
|
||||
metrics: {
|
||||
docs24h: 28941,
|
||||
throughputPerMin: 22,
|
||||
errorRate: 0.004,
|
||||
p95LatencyMs: 412,
|
||||
uptime: 0.9998,
|
||||
},
|
||||
stages: stages(
|
||||
["OCR", "Classify", "Extract"],
|
||||
["Schema validate", "Confidence bounds"],
|
||||
["Compress"],
|
||||
["Redact PII", "Encryption at rest"],
|
||||
["Primary store", "Processing manifest"],
|
||||
),
|
||||
golden: { passing: 36, total: 36, lastRun: "2h ago", threshold: 0.95 },
|
||||
drift: [],
|
||||
};
|
||||
|
||||
const PRIOR_AUTH: Pipeline = {
|
||||
id: "pl-prior-auth",
|
||||
name: "Prior Auth Router",
|
||||
blurb: "Prior-authorization intake → medical-necessity gate → payer webhook",
|
||||
status: "degraded",
|
||||
source: "Inbound webhook",
|
||||
destination: "Outbound webhook",
|
||||
version: "v3.1.0",
|
||||
regions: ["us-east-1"],
|
||||
metrics: {
|
||||
docs24h: 11204,
|
||||
throughputPerMin: 9,
|
||||
errorRate: 0.031,
|
||||
p95LatencyMs: 740,
|
||||
uptime: 0.9962,
|
||||
},
|
||||
stages: stages(
|
||||
["OCR", "Classify", "Extract"],
|
||||
["Schema validate", "Counterparty match", "Confidence bounds"],
|
||||
["Convert"],
|
||||
["Redact PII", "PII/PHI enforcement", "Encryption at rest"],
|
||||
["Conditional routing", "Human review"],
|
||||
),
|
||||
// Below its own 0.90 bound (24/28 ≈ 0.857) — the at-a-glance reliability miss.
|
||||
golden: { passing: 24, total: 28, lastRun: "47m ago", threshold: 0.9 },
|
||||
drift: [
|
||||
{
|
||||
field: "procedure_codes",
|
||||
note: "New CPT modifier suffix not seen in prior examples",
|
||||
confidenceDelta: -0.07,
|
||||
severity: "warning",
|
||||
affectedDocs: 18,
|
||||
},
|
||||
{
|
||||
field: "payer",
|
||||
note: "Two payers now emit a merged-entity name",
|
||||
confidenceDelta: -0.03,
|
||||
severity: "info",
|
||||
affectedDocs: 6,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const INVOICE_AP: Pipeline = {
|
||||
id: "pl-invoice-ap",
|
||||
name: "Invoice → AP",
|
||||
blurb: "Invoice extraction → three-way match → Postgres",
|
||||
status: "healthy",
|
||||
source: "S3 bucket watch",
|
||||
destination: "Database",
|
||||
version: "v2.8.0",
|
||||
regions: ["us-east-1", "eu-west-1", "ap-southeast-1"],
|
||||
metrics: {
|
||||
docs24h: 53120,
|
||||
throughputPerMin: 41,
|
||||
errorRate: 0.006,
|
||||
p95LatencyMs: 358,
|
||||
uptime: 0.9997,
|
||||
},
|
||||
stages: stages(
|
||||
["Parse", "Classify", "Extract"],
|
||||
["Schema validate", "Confidence bounds"],
|
||||
["PDF → CSV"],
|
||||
["Redact PII", "Encryption at rest"],
|
||||
["Primary store", "Mirror to bucket", "Notify"],
|
||||
),
|
||||
golden: { passing: 41, total: 42, lastRun: "1h ago", threshold: 0.95 },
|
||||
drift: [
|
||||
{
|
||||
field: "tax",
|
||||
note: "EU reverse-charge invoices omit a line-level tax field",
|
||||
confidenceDelta: -0.02,
|
||||
severity: "info",
|
||||
affectedDocs: 4,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const CONTRACT_REVIEW: Pipeline = {
|
||||
id: "pl-contract",
|
||||
name: "Contract Review",
|
||||
blurb: "Contract intake → clause-risk analysis → review queue",
|
||||
status: "healthy",
|
||||
source: "Upload API",
|
||||
destination: "Another pipeline",
|
||||
version: "v1.9.2",
|
||||
regions: ["eu-west-1"],
|
||||
metrics: {
|
||||
docs24h: 4380,
|
||||
throughputPerMin: 4,
|
||||
errorRate: 0.009,
|
||||
p95LatencyMs: 1280,
|
||||
uptime: 0.9991,
|
||||
},
|
||||
stages: stages(
|
||||
["OCR", "Classify"],
|
||||
["Contract analyzer", "Authenticity", "Confidence bounds"],
|
||||
["Document summarizer"],
|
||||
["Redact PII", "Confidentiality mark", "Signed outputs"],
|
||||
["Human review", "Flag"],
|
||||
),
|
||||
golden: { passing: 31, total: 33, lastRun: "5h ago", threshold: 0.95 },
|
||||
drift: [],
|
||||
};
|
||||
|
||||
const KYC_PROCESSOR: Pipeline = {
|
||||
id: "pl-kyc",
|
||||
name: "KYC Processor",
|
||||
blurb:
|
||||
"Identity-document intake → authenticity + sanctions → compliance archive",
|
||||
status: "healthy",
|
||||
source: "Scheduled import",
|
||||
destination: "Compliance archive",
|
||||
version: "v4.0.3",
|
||||
regions: ["us-east-1", "eu-west-1", "ap-southeast-1"],
|
||||
metrics: {
|
||||
docs24h: 19870,
|
||||
throughputPerMin: 15,
|
||||
errorRate: 0.005,
|
||||
p95LatencyMs: 503,
|
||||
uptime: 0.9999,
|
||||
},
|
||||
stages: stages(
|
||||
["OCR", "Classify", "Extract"],
|
||||
["Authenticity", "Tamper check", "Counterparty match"],
|
||||
["Convert"],
|
||||
["Field-aware redact", "Attribution watermark", "Encryption at rest"],
|
||||
["Compliance archive", "Processing manifest", "Notify"],
|
||||
),
|
||||
golden: { passing: 52, total: 54, lastRun: "31m ago", threshold: 0.95 },
|
||||
drift: [
|
||||
{
|
||||
field: "document_number",
|
||||
note: "New passport series uses a 9-char alphanumeric format",
|
||||
confidenceDelta: -0.04,
|
||||
severity: "warning",
|
||||
affectedDocs: 11,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Enterprise-only evals note. Surfaced as a banner: shadow + comparative eval
|
||||
* runs gate every promotion, so the deployed fleet always trails a quietly
|
||||
* running candidate.
|
||||
*/
|
||||
export interface EvalsNote {
|
||||
/** Pipelines currently running a shadow eval against production traffic. */
|
||||
shadowCount: number;
|
||||
/** Comparative (champion/challenger) runs awaiting sign-off. */
|
||||
comparativeCount: number;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
const ENTERPRISE_EVALS: EvalsNote = {
|
||||
shadowCount: 2,
|
||||
comparativeCount: 2,
|
||||
detail:
|
||||
"Prior Auth v3.2.0-rc and Invoice v2.9.0-rc are mirroring live traffic in shadow; KYC v4.1.0 is in a comparative run against v4.0.3. The Contract Review v2.0.0 candidate is blocked — it regressed 3 golden cases on clause-risk scoring, so the comparative run is held until the candidate is re-cut. No promotion happens until a candidate clears its golden set and the comparative delta stays inside bounds.",
|
||||
};
|
||||
|
||||
/**
|
||||
* A pipeline that began life as an Editor watch-folder flow and was promoted
|
||||
* into the portal. These are the on-ramp from ad-hoc desktop automation to a
|
||||
* governed, deployed pipeline — they keep a pointer back to the watch folder
|
||||
* they grew out of so the lineage stays visible.
|
||||
*/
|
||||
export interface PromotedPipeline {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Doc type the originating watch-folder flow was built around. */
|
||||
sourceDocType: string;
|
||||
/** The Editor watch folder this was promoted from. */
|
||||
watchFolder: string;
|
||||
/** Where the promotion sits in its lifecycle. */
|
||||
status: PromotedStatus;
|
||||
/** When the promotion landed. */
|
||||
promotedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A promoted flow is `deployed` once it runs in the portal, `staged` while it
|
||||
* mirrors the watch folder without taking over, and `review` when it needs a
|
||||
* human to confirm the flow before it goes live.
|
||||
*/
|
||||
export type PromotedStatus = "deployed" | "staged" | "review";
|
||||
|
||||
export interface PipelinesResponse {
|
||||
pipelines: Pipeline[];
|
||||
/** Present for enterprise only. */
|
||||
evals: EvalsNote | null;
|
||||
/** Flows promoted up from Editor watch folders. Empty on free. */
|
||||
promoted: PromotedPipeline[];
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Promoted-from-Editor fixtures */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const PROMOTED_PRO: PromotedPipeline[] = [
|
||||
{
|
||||
id: "pl-promo-statements",
|
||||
name: "Bank Statement Normalizer",
|
||||
sourceDocType: "Bank statement",
|
||||
watchFolder: "~/StirlingWatch/statements-in",
|
||||
status: "deployed",
|
||||
promotedAt: "promoted 3d ago",
|
||||
},
|
||||
{
|
||||
id: "pl-promo-receipts",
|
||||
name: "Receipt Splitter",
|
||||
sourceDocType: "Expense receipt",
|
||||
watchFolder: "~/StirlingWatch/receipts",
|
||||
status: "staged",
|
||||
promotedAt: "promoted 11h ago",
|
||||
},
|
||||
];
|
||||
|
||||
const PROMOTED_ENTERPRISE: PromotedPipeline[] = [
|
||||
...PROMOTED_PRO,
|
||||
{
|
||||
id: "pl-promo-claims",
|
||||
name: "Claims Intake Splitter",
|
||||
sourceDocType: "Insurance claim",
|
||||
watchFolder: "\\\\fileserver\\ClaimsDropbox",
|
||||
status: "deployed",
|
||||
promotedAt: "promoted 6d ago",
|
||||
},
|
||||
{
|
||||
id: "pl-promo-onboarding",
|
||||
name: "New-Hire Packet Sorter",
|
||||
sourceDocType: "Onboarding packet",
|
||||
watchFolder: "\\\\hr-share\\NewHireScans",
|
||||
status: "review",
|
||||
promotedAt: "promoted 2h ago",
|
||||
},
|
||||
];
|
||||
|
||||
export function pipelinesFor(tier: Tier): PipelinesResponse {
|
||||
if (tier === "free") {
|
||||
return { pipelines: [], evals: null, promoted: [] };
|
||||
}
|
||||
if (tier === "enterprise") {
|
||||
return {
|
||||
pipelines: [
|
||||
INVOICE_AP,
|
||||
COI_COMPLIANCE,
|
||||
PRIOR_AUTH,
|
||||
KYC_PROCESSOR,
|
||||
CONTRACT_REVIEW,
|
||||
],
|
||||
evals: ENTERPRISE_EVALS,
|
||||
promoted: PROMOTED_ENTERPRISE,
|
||||
};
|
||||
}
|
||||
// pro
|
||||
return {
|
||||
pipelines: [COI_COMPLIANCE, INVOICE_AP, PRIOR_AUTH],
|
||||
evals: null,
|
||||
promoted: PROMOTED_PRO,
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.portal-pipelines__header {
|
||||
.portal-pipelines__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
@@ -30,574 +30,280 @@
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
/* Fleet summary chips */
|
||||
.portal-pipelines__fleet {
|
||||
/* Table cells */
|
||||
.portal-pipelines__name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__fleet-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* List */
|
||||
.portal-pipelines__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__card-title {
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-pipelines__card-blurb {
|
||||
margin: 0.125rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
/* Source → stages → destination rail */
|
||||
.portal-pipelines__card-rail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.portal-pipelines__rail-chip {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-3);
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 0.1875rem 0.5rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__rail-arrow {
|
||||
color: var(--color-text-5);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__stage-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__stage-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Metric strip on the card */
|
||||
.portal-pipelines__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
.portal-pipelines__metrics {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.portal-pipelines__card-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-pipelines__card-version {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.portal-pipelines__card-golden {
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-pipelines__card-drift {
|
||||
color: var(--color-amber-dark);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Detail drawer */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.portal-pipelines__detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__detail-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.625rem;
|
||||
padding: 0.875rem;
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.portal-pipelines__detail-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__detail-h {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-pipelines__detail-sub {
|
||||
margin: -0.375rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* The five silent stages */
|
||||
.portal-pipelines__stages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__stage {
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.portal-pipelines__stage-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__stage-pip {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.portal-pipelines__stage-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.portal-pipelines__stage-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__stage-empty {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-5);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Golden set */
|
||||
.portal-pipelines__golden {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.portal-pipelines__golden-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__golden-when {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* Schema drift */
|
||||
.portal-pipelines__drift-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__drift {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.portal-pipelines__drift-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.portal-pipelines__drift-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-pipelines__drift-field {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.portal-pipelines__drift-note {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-pipelines__drift-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.125rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Composer modal */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.portal-pipelines__composer {
|
||||
min-height: 22rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__composer-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__composer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
@media (max-width: 44rem) {
|
||||
.portal-pipelines__composer-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.portal-pipelines__option {
|
||||
.portal-pipelines__name-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-pipelines__name-text strong {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-pipelines__pipe-dot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.875rem;
|
||||
height: 1.875rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--motion-fast),
|
||||
background var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-pipelines__option:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.portal-pipelines__option.is-selected {
|
||||
border-color: var(--color-blue);
|
||||
font-size: 0.875rem;
|
||||
background: var(--color-blue-light);
|
||||
}
|
||||
|
||||
.portal-pipelines__option-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-pipelines__option-desc {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Op chain summary */
|
||||
.portal-pipelines__chain {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.portal-pipelines__chain-label,
|
||||
.portal-pipelines__agents-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.portal-pipelines__chain-chips,
|
||||
.portal-pipelines__agents-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__chain-empty {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-5);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.portal-pipelines__agents {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4375rem;
|
||||
}
|
||||
|
||||
/* Op library picker */
|
||||
.portal-pipelines__library {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
max-height: 19rem;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.portal-pipelines__library-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4375rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__library-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.portal-pipelines__library-pip {
|
||||
width: 0.4375rem;
|
||||
height: 0.4375rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.portal-pipelines__library-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
/* Routing alerts */
|
||||
.portal-pipelines__alerts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__alert {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: start;
|
||||
gap: 0.625rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.portal-pipelines__alert input {
|
||||
margin-top: 0.1875rem;
|
||||
accent-color: var(--color-blue);
|
||||
}
|
||||
|
||||
.portal-pipelines__alert > span {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.0625rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__alert strong {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-pipelines__alert span span {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* Composer step indicator in the footer */
|
||||
.portal-pipelines__composer-steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.portal-pipelines__composer-step {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-5);
|
||||
}
|
||||
|
||||
.portal-pipelines__composer-step.is-active {
|
||||
color: var(--color-blue);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.portal-pipelines__composer-step.is-done {
|
||||
color: var(--color-green-dark);
|
||||
.portal-pipelines__muted {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-4);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Roster + promoted sections */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
.portal-pipelines__caret {
|
||||
display: inline-block;
|
||||
color: var(--color-text-5);
|
||||
transition: transform var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-pipelines__section {
|
||||
.portal-pipelines__caret.is-open {
|
||||
transform: rotate(90deg);
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
/* Expanded detail panel */
|
||||
.portal-pipelines__expanded {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 1.25rem;
|
||||
animation: portal-pipelines-reveal var(--motion-fast) ease-out;
|
||||
}
|
||||
|
||||
@keyframes portal-pipelines-reveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-0.25rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.portal-pipelines__expanded-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding-bottom: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.portal-pipelines__section-h {
|
||||
.portal-pipelines__expanded-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-pipelines__section-sub {
|
||||
margin: -0.25rem 0 0.25rem;
|
||||
.portal-pipelines__expanded-sub {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
/* Golden-set roster table */
|
||||
.portal-pipelines__roster-name,
|
||||
.portal-pipelines__promoted-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
.portal-pipelines__expanded-close {
|
||||
margin-left: auto;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-4);
|
||||
font-size: 1.125rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-pipelines__roster-name strong,
|
||||
.portal-pipelines__promoted-name strong {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
.portal-pipelines__expanded-close:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-pipelines__roster-route,
|
||||
.portal-pipelines__promoted-when {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
/* Detail body */
|
||||
.portal-pipelines__detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.125rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__roster-golden {
|
||||
.portal-pipelines__detail-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__detail-heading {
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-5);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.portal-pipelines__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__detail-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.875rem;
|
||||
margin-top: 1rem;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
/* Table skeleton */
|
||||
.portal-pipelines__table-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Composer */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.portal-pipelines__composer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__composer-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__composer-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.portal-pipelines__source-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-height: 12rem;
|
||||
overflow-y: auto;
|
||||
padding: 0.625rem;
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* Operation chain */
|
||||
.portal-pipelines__chain {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__chain-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.4375rem 0.625rem;
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.portal-pipelines__chain-index {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
background: var(--color-blue-light);
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
.portal-pipelines__chain-op {
|
||||
flex: 1;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-pipelines__chain-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__chain-actions button {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-3);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-pipelines__chain-actions button:hover:not(:disabled) {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-pipelines__chain-actions button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.portal-pipelines__op-palette {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
/* Schedule row */
|
||||
.portal-pipelines__schedule {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__roster-rate {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.portal-pipelines__roster-num,
|
||||
.portal-pipelines__roster-version {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.portal-pipelines__roster-version {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Promoted-from-Editor table */
|
||||
.portal-pipelines__promoted-muted {
|
||||
color: var(--color-text-3);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__promoted-folder {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-3);
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.0625rem 0.375rem;
|
||||
.portal-pipelines__schedule-count {
|
||||
width: 5rem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { HttpError } from "@portal/api/http";
|
||||
import type { PipelinesOverviewResponse, Policy } from "@portal/api/pipelines";
|
||||
import type { SourcesResponse } from "@portal/api/sources";
|
||||
import { Pipelines } from "@portal/views/Pipelines";
|
||||
|
||||
// Deterministic i18n: keys returned verbatim, so assertions are stable without
|
||||
// the async TOML backend.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { changeLanguage: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
const fetchPipelines = vi.fn();
|
||||
const fetchPipeline = vi.fn();
|
||||
const savePipeline = vi.fn();
|
||||
const deletePipeline = vi.fn();
|
||||
const fetchTriggers = vi.fn();
|
||||
const triggerPipeline = vi.fn();
|
||||
const fetchRun = vi.fn();
|
||||
vi.mock("@portal/api/pipelines", () => ({
|
||||
fetchPipelines: () => fetchPipelines(),
|
||||
fetchPipeline: (id: string) => fetchPipeline(id),
|
||||
savePipeline: (policy: unknown) => savePipeline(policy),
|
||||
deletePipeline: (id: string) => deletePipeline(id),
|
||||
fetchTriggers: () => fetchTriggers(),
|
||||
triggerPipeline: (id: string) => triggerPipeline(id),
|
||||
fetchRun: (runId: string) => fetchRun(runId),
|
||||
}));
|
||||
|
||||
const fetchSources = vi.fn();
|
||||
vi.mock("@portal/api/sources", () => ({
|
||||
fetchSources: () => fetchSources(),
|
||||
}));
|
||||
|
||||
const RESPONSE: PipelinesOverviewResponse = {
|
||||
kpis: [
|
||||
{ value: 2, description: "" },
|
||||
{ value: 2, description: "" },
|
||||
{ value: 0, description: "" },
|
||||
],
|
||||
pipelines: [
|
||||
{
|
||||
id: "plc-redaction",
|
||||
name: "Redaction sweep",
|
||||
enabled: true,
|
||||
status: "active",
|
||||
trigger: "schedule",
|
||||
sources: [{ id: "src-claims", name: "Claims intake" }],
|
||||
steps: ["/api/v1/security/auto-redact"],
|
||||
output: "inline",
|
||||
owner: "security@acme.com",
|
||||
},
|
||||
{
|
||||
id: "plc-archive",
|
||||
name: "Archive compressor",
|
||||
enabled: true,
|
||||
status: "active",
|
||||
trigger: "manual",
|
||||
sources: [],
|
||||
steps: ["/api/v1/misc/compress-pdf"],
|
||||
output: "folder",
|
||||
owner: "data@acme.com",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const RAW_REDACTION: Policy = {
|
||||
id: "plc-redaction",
|
||||
name: "Redaction sweep",
|
||||
enabled: true,
|
||||
trigger: {
|
||||
type: "schedule",
|
||||
options: { schedule: { type: "every", count: 6, unit: "HOURS" } },
|
||||
},
|
||||
sourceIds: ["src-claims"],
|
||||
steps: [{ operation: "/api/v1/security/auto-redact", parameters: {} }],
|
||||
output: { type: "inline", options: {} },
|
||||
};
|
||||
|
||||
const SOURCES: SourcesResponse = {
|
||||
kpis: [],
|
||||
sources: [
|
||||
{
|
||||
id: "src-claims",
|
||||
name: "Claims intake",
|
||||
type: "folder",
|
||||
status: "active",
|
||||
referenceCount: 1,
|
||||
referencingPolicies: [],
|
||||
config: [],
|
||||
docsTotal: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function renderView() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<Pipelines />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("Pipelines view", () => {
|
||||
beforeEach(() => {
|
||||
fetchPipelines.mockReset();
|
||||
fetchPipeline.mockReset();
|
||||
savePipeline.mockReset();
|
||||
deletePipeline.mockReset();
|
||||
fetchSources.mockReset();
|
||||
fetchTriggers.mockReset();
|
||||
triggerPipeline.mockReset();
|
||||
fetchRun.mockReset();
|
||||
// The composer loads the trigger registry on open; default to none.
|
||||
fetchTriggers.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("surfaces the inline error message when a delete fails", async () => {
|
||||
fetchPipelines.mockResolvedValue(RESPONSE);
|
||||
deletePipeline.mockRejectedValue(
|
||||
new HttpError(500, "Server Error", {
|
||||
detail: "Could not delete pipeline",
|
||||
}),
|
||||
);
|
||||
|
||||
renderView();
|
||||
|
||||
fireEvent.click(await screen.findByText("Redaction sweep"));
|
||||
fireEvent.click(await screen.findByText("pipelines.detail.delete"));
|
||||
fireEvent.click(await screen.findByText("pipelines.delete.confirm"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deletePipeline).toHaveBeenCalledWith("plc-redaction");
|
||||
});
|
||||
expect(
|
||||
await screen.findByText("Could not delete pipeline"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pauses a pipeline by re-saving it with enabled flipped off", async () => {
|
||||
fetchPipelines.mockResolvedValue(RESPONSE);
|
||||
fetchPipeline.mockResolvedValue(RAW_REDACTION);
|
||||
savePipeline.mockResolvedValue({});
|
||||
|
||||
renderView();
|
||||
|
||||
fireEvent.click(await screen.findByText("Redaction sweep"));
|
||||
fireEvent.click(await screen.findByText("pipelines.detail.pause"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(savePipeline).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(fetchPipeline).toHaveBeenCalledWith("plc-redaction");
|
||||
expect(savePipeline).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "plc-redaction", enabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("runs a pipeline now and reports success inline", async () => {
|
||||
fetchPipelines.mockResolvedValue(RESPONSE);
|
||||
triggerPipeline.mockResolvedValue(["run-1"]);
|
||||
fetchRun.mockResolvedValue({
|
||||
runId: "run-1",
|
||||
policyId: "plc-redaction",
|
||||
status: "COMPLETED",
|
||||
currentStep: 1,
|
||||
stepCount: 1,
|
||||
error: null,
|
||||
errorCode: null,
|
||||
createdAt: 0,
|
||||
});
|
||||
|
||||
renderView();
|
||||
|
||||
fireEvent.click(await screen.findByText("Redaction sweep"));
|
||||
fireEvent.click(await screen.findByText("pipelines.detail.run"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(triggerPipeline).toHaveBeenCalledWith("plc-redaction");
|
||||
});
|
||||
expect(
|
||||
await screen.findByText("pipelines.run.completed"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces an execution failure from a manual run", async () => {
|
||||
fetchPipelines.mockResolvedValue(RESPONSE);
|
||||
triggerPipeline.mockResolvedValue(["run-1"]);
|
||||
fetchRun.mockResolvedValue({
|
||||
runId: "run-1",
|
||||
policyId: "plc-redaction",
|
||||
status: "FAILED",
|
||||
currentStep: 1,
|
||||
stepCount: 1,
|
||||
error: "step 1 blew up",
|
||||
errorCode: null,
|
||||
createdAt: 0,
|
||||
});
|
||||
|
||||
renderView();
|
||||
|
||||
fireEvent.click(await screen.findByText("Redaction sweep"));
|
||||
fireEvent.click(await screen.findByText("pipelines.detail.run"));
|
||||
|
||||
expect(await screen.findByText("pipelines.run.failed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates a pipeline with the chosen name and chained operation", async () => {
|
||||
fetchPipelines.mockResolvedValue(RESPONSE);
|
||||
fetchSources.mockResolvedValue(SOURCES);
|
||||
savePipeline.mockResolvedValue({});
|
||||
|
||||
renderView();
|
||||
|
||||
// Wait for the table so the initial fetch has settled, then open the composer.
|
||||
await screen.findByText("Redaction sweep");
|
||||
fireEvent.click(screen.getByText("pipelines.actions.newPipeline"));
|
||||
|
||||
fireEvent.change(await screen.findByRole("textbox"), {
|
||||
target: { value: "Nightly compress" },
|
||||
});
|
||||
// Operation palette chip labels are derived from the endpoint path.
|
||||
fireEvent.click(await screen.findByText("+ Compress"));
|
||||
fireEvent.click(screen.getByText("pipelines.composer.create"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(savePipeline).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(savePipeline).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "Nightly compress",
|
||||
trigger: null,
|
||||
output: expect.objectContaining({ type: "inline" }),
|
||||
steps: [
|
||||
expect.objectContaining({ operation: "/api/v1/misc/compress-pdf" }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,162 +1,205 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Drawer,
|
||||
EmptyState,
|
||||
StatusBadge,
|
||||
Modal,
|
||||
Skeleton,
|
||||
} from "@shared/components";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
import { errorMessage } from "@portal/api/http";
|
||||
import {
|
||||
deletePipeline,
|
||||
fetchPipeline,
|
||||
fetchPipelines,
|
||||
type Pipeline,
|
||||
type PipelinesResponse,
|
||||
savePipeline,
|
||||
type PipelinesOverviewResponse,
|
||||
type PipelineView,
|
||||
type Policy,
|
||||
} from "@portal/api/pipelines";
|
||||
import { DeployedPipelinesTable } from "@portal/components/pipelines/DeployedPipelinesTable";
|
||||
import { PipelineCard } from "@portal/components/pipelines/PipelineCard";
|
||||
import { KpiStrip } from "@portal/components/pipelines/KpiStrip";
|
||||
import { PipelinesTable } from "@portal/components/pipelines/PipelinesTable";
|
||||
import { PipelineDetailCard } from "@portal/components/pipelines/PipelineDetailCard";
|
||||
import { PipelineComposer } from "@portal/components/pipelines/PipelineComposer";
|
||||
import { PipelineDetail } from "@portal/components/pipelines/PipelineDetail";
|
||||
import { PipelineListSkeleton } from "@portal/components/pipelines/PipelineListSkeleton";
|
||||
import { PromotedPipelines } from "@portal/components/pipelines/PromotedPipelines";
|
||||
import "@portal/views/Pipelines.css";
|
||||
|
||||
export function Pipelines() {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
const state = useAsync<PipelinesResponse>(() => fetchPipelines(tier), [tier]);
|
||||
const { data } = state;
|
||||
const { isLoading } = useSectionFlags(state);
|
||||
// Refetch after every mutation by bumping this counter, so the table reflects
|
||||
// the backend (mirrors the Sources view).
|
||||
const [version, setVersion] = useState(0);
|
||||
const state = useAsync<PipelinesOverviewResponse>(
|
||||
() => fetchPipelines(),
|
||||
[version],
|
||||
);
|
||||
const { data, loading } = state;
|
||||
const { isLoading, isEmpty } = useSectionFlags(state);
|
||||
const refetch = useCallback(() => setVersion((v) => v + 1), []);
|
||||
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [composerOpen, setComposerOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<Pipeline | null>(null);
|
||||
const [editing, setEditing] = useState<Policy | null>(null);
|
||||
const [mutating, setMutating] = useState(false);
|
||||
const [pageError, setPageError] = useState<string | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<PipelineView | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const pipelines = data?.pipelines ?? [];
|
||||
const evals = data?.evals ?? null;
|
||||
const promoted = data?.promoted ?? [];
|
||||
const isEmpty = !isLoading && pipelines.length === 0;
|
||||
const expanded = pipelines.find((p) => p.id === expandedId) ?? null;
|
||||
|
||||
const fleetHealthy = useMemo(
|
||||
() => pipelines.filter((p) => p.status === "healthy").length,
|
||||
[pipelines],
|
||||
);
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
setComposerOpen(true);
|
||||
}
|
||||
|
||||
// Editing needs the raw policy (steps, trigger, source ids), which the overview
|
||||
// rows don't carry, so fetch it before opening the composer prefilled.
|
||||
async function openEdit(pipeline: PipelineView) {
|
||||
if (mutating) return;
|
||||
setPageError(null);
|
||||
setMutating(true);
|
||||
try {
|
||||
setEditing(await fetchPipeline(pipeline.id));
|
||||
setComposerOpen(true);
|
||||
} catch (e) {
|
||||
setPageError(errorMessage(e));
|
||||
} finally {
|
||||
setMutating(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Pause/resume: re-save the policy with enabled flipped (the backend has no
|
||||
// dedicated endpoint; every mutation routes through POST /policies). Fetch the
|
||||
// raw record first so the full config round-trips intact.
|
||||
async function togglePause(pipeline: PipelineView) {
|
||||
if (mutating) return;
|
||||
setPageError(null);
|
||||
setMutating(true);
|
||||
try {
|
||||
const raw = await fetchPipeline(pipeline.id);
|
||||
await savePipeline({ ...raw, enabled: !raw.enabled });
|
||||
refetch();
|
||||
} catch (e) {
|
||||
setPageError(errorMessage(e));
|
||||
} finally {
|
||||
setMutating(false);
|
||||
}
|
||||
}
|
||||
|
||||
function requestDelete(pipeline: PipelineView) {
|
||||
setDeleteError(null);
|
||||
setPendingDelete(pipeline);
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!pendingDelete || deleting) return;
|
||||
setDeleting(true);
|
||||
setDeleteError(null);
|
||||
try {
|
||||
await deletePipeline(pendingDelete.id);
|
||||
setPendingDelete(null);
|
||||
setExpandedId(null);
|
||||
refetch();
|
||||
} catch (e) {
|
||||
setDeleteError(errorMessage(e));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="portal-pipelines">
|
||||
<header className="portal-pipelines__header">
|
||||
<header className="portal-pipelines__head">
|
||||
<div>
|
||||
<h1 className="portal-pipelines__title">{t("pipelines.title")}</h1>
|
||||
<p className="portal-pipelines__sub">{t("pipelines.subtitle")}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="gradient"
|
||||
onClick={() => setComposerOpen(true)}
|
||||
leadingIcon={<span aria-hidden>+</span>}
|
||||
>
|
||||
{t("pipelines.newPipeline")}
|
||||
<Button onClick={openCreate} leadingIcon={<span aria-hidden>+</span>}>
|
||||
{t("pipelines.actions.newPipeline")}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{!isLoading && pipelines.length > 0 && (
|
||||
<div className="portal-pipelines__fleet">
|
||||
<StatusBadge tone="success" size="sm">
|
||||
{t("pipelines.fleet.healthy", { count: fleetHealthy })}
|
||||
</StatusBadge>
|
||||
{fleetHealthy < pipelines.length && (
|
||||
<StatusBadge tone="warning" size="sm">
|
||||
{t("pipelines.fleet.degraded", {
|
||||
count: pipelines.length - fleetHealthy,
|
||||
})}
|
||||
</StatusBadge>
|
||||
)}
|
||||
<span className="portal-pipelines__fleet-count">
|
||||
{t("pipelines.fleet.deployed", { count: pipelines.length })}
|
||||
</span>
|
||||
{pageError && <Banner tone="danger" description={pageError} />}
|
||||
|
||||
<KpiStrip data={data} loading={loading} />
|
||||
|
||||
{isLoading && (
|
||||
<div className="portal-pipelines__table-skeleton" aria-hidden>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} height="3rem" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tier === "enterprise" && evals && (
|
||||
<Banner tone="info" title={t("pipelines.evals.title")}>
|
||||
{t("pipelines.evals.body", {
|
||||
count: evals.shadowCount,
|
||||
comparativeCount: evals.comparativeCount,
|
||||
detail: evals.detail,
|
||||
})}
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{isLoading && <PipelineListSkeleton />}
|
||||
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
title={t("pipelines.empty.title")}
|
||||
description={t("pipelines.empty.description")}
|
||||
actions={
|
||||
<Button variant="gradient" onClick={() => setComposerOpen(true)}>
|
||||
{t("pipelines.empty.action")}
|
||||
</Button>
|
||||
<Button onClick={openCreate}>{t("pipelines.empty.action")}</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pipelines.length > 0 && (
|
||||
<section className="portal-pipelines__section">
|
||||
<h2 className="portal-pipelines__section-h">
|
||||
{t("pipelines.reliability.heading")}
|
||||
</h2>
|
||||
<p className="portal-pipelines__section-sub">
|
||||
{t("pipelines.reliability.description")}
|
||||
</p>
|
||||
<DeployedPipelinesTable
|
||||
pipelines={pipelines}
|
||||
onRowClick={setSelected}
|
||||
/>
|
||||
</section>
|
||||
{!isLoading && !isEmpty && pipelines.length > 0 && (
|
||||
<PipelinesTable
|
||||
pipelines={pipelines}
|
||||
expandedId={expandedId}
|
||||
onRowClick={(p) =>
|
||||
setExpandedId((cur) => (cur === p.id ? null : p.id))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pipelines.length > 0 && (
|
||||
<div className="portal-pipelines__list">
|
||||
{pipelines.map((p) => (
|
||||
<PipelineCard key={p.id} pipeline={p} onOpen={setSelected} />
|
||||
))}
|
||||
</div>
|
||||
{expanded && (
|
||||
<PipelineDetailCard
|
||||
pipeline={expanded}
|
||||
onClose={() => setExpandedId(null)}
|
||||
onEdit={openEdit}
|
||||
onTogglePause={togglePause}
|
||||
onDelete={requestDelete}
|
||||
busy={mutating}
|
||||
/>
|
||||
)}
|
||||
|
||||
{promoted.length > 0 && (
|
||||
<section className="portal-pipelines__section">
|
||||
<h2 className="portal-pipelines__section-h">
|
||||
{t("pipelines.promoted.heading")}
|
||||
</h2>
|
||||
<p className="portal-pipelines__section-sub">
|
||||
{t("pipelines.promoted.description")}
|
||||
</p>
|
||||
<PromotedPipelines promoted={promoted} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Drawer
|
||||
open={selected !== null}
|
||||
onClose={() => setSelected(null)}
|
||||
width="lg"
|
||||
title={selected?.name}
|
||||
subtitle={
|
||||
selected
|
||||
? t("pipelines.detail.subtitle", {
|
||||
version: selected.version,
|
||||
source: selected.source,
|
||||
destination: selected.destination,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{selected && <PipelineDetail pipeline={selected} />}
|
||||
</Drawer>
|
||||
|
||||
<PipelineComposer
|
||||
open={composerOpen}
|
||||
pipeline={editing ?? undefined}
|
||||
onClose={() => setComposerOpen(false)}
|
||||
onSaved={refetch}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={pendingDelete !== null}
|
||||
onClose={() => !deleting && setPendingDelete(null)}
|
||||
width="sm"
|
||||
title={t("pipelines.delete.title")}
|
||||
footer={
|
||||
<div className="portal-pipelines__composer-footer">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={deleting}
|
||||
onClick={() => setPendingDelete(null)}
|
||||
>
|
||||
{t("pipelines.delete.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
accent="red"
|
||||
loading={deleting}
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
{t("pipelines.delete.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p>{t("pipelines.delete.body", { name: pendingDelete?.name ?? "" })}</p>
|
||||
{deleteError && <Banner tone="danger" description={deleteError} />}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
|
||||
// Visual catalogue of the shared brand assets (shared/assets/brand) — the single
|
||||
// source of truth for the Stirling logos used across the apps.
|
||||
import modernMarkDark from "@shared/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg";
|
||||
import modernMarkLight from "@shared/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg";
|
||||
import modernBlack from "@shared/assets/brand/modern-logo/StirlingPDFLogoBlackText.svg";
|
||||
import modernWhite from "@shared/assets/brand/modern-logo/StirlingPDFLogoWhiteText.svg";
|
||||
import modernGrey from "@shared/assets/brand/modern-logo/StirlingPDFLogoGreyText.svg";
|
||||
import classicMarkDark from "@shared/assets/brand/classic-logo/StirlingPDFLogoNoTextDark.svg";
|
||||
import classicMarkLight from "@shared/assets/brand/classic-logo/StirlingPDFLogoNoTextLight.svg";
|
||||
import classicBlack from "@shared/assets/brand/classic-logo/StirlingPDFLogoBlackText.svg";
|
||||
import classicWhite from "@shared/assets/brand/classic-logo/StirlingPDFLogoWhiteText.svg";
|
||||
import classicGrey from "@shared/assets/brand/classic-logo/StirlingPDFLogoGreyText.svg";
|
||||
|
||||
type Asset = { label: string; src: string; onDark?: boolean };
|
||||
type VariantSet = { variant: string; mark: Asset[]; wordmark: Asset[] };
|
||||
|
||||
const SETS: VariantSet[] = [
|
||||
{
|
||||
variant: "modern",
|
||||
mark: [
|
||||
{ label: "NoTextDark", src: modernMarkDark },
|
||||
{ label: "NoTextLight", src: modernMarkLight, onDark: true },
|
||||
],
|
||||
wordmark: [
|
||||
{ label: "BlackText", src: modernBlack },
|
||||
{ label: "GreyText", src: modernGrey },
|
||||
{ label: "WhiteText", src: modernWhite, onDark: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
variant: "classic",
|
||||
mark: [
|
||||
{ label: "NoTextDark", src: classicMarkDark },
|
||||
{ label: "NoTextLight", src: classicMarkLight, onDark: true },
|
||||
],
|
||||
wordmark: [
|
||||
{ label: "BlackText", src: classicBlack },
|
||||
{ label: "GreyText", src: classicGrey },
|
||||
{ label: "WhiteText", src: classicWhite, onDark: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function Swatch({ label, src, onDark, h }: Asset & { h: number }) {
|
||||
return (
|
||||
<figure
|
||||
style={{ margin: 0, display: "grid", gap: 6, justifyItems: "center" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
padding: 16,
|
||||
minWidth: 140,
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(128,128,128,0.25)",
|
||||
background: onDark ? "#1a1a1a" : "#ffffff",
|
||||
}}
|
||||
>
|
||||
<img src={src} alt={label} style={{ height: h, maxWidth: 200 }} />
|
||||
</div>
|
||||
<figcaption style={{ fontSize: 12, color: "var(--text-muted, #71717a)" }}>
|
||||
{label}
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ title, items }: { title: string; items: Asset[] }) {
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
<h4 style={{ margin: 0, textTransform: "capitalize" }}>{title}</h4>
|
||||
<div style={{ display: "flex", gap: 16, flexWrap: "wrap" }}>
|
||||
{items.map((a) => (
|
||||
<Swatch key={a.label} {...a} h={title === "mark" ? 48 : 28} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Brand/Logos",
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj;
|
||||
|
||||
/** Every brand mark + wordmark, per variant, on the background each is built for. */
|
||||
export const Logos: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "grid", gap: 32 }}>
|
||||
{SETS.map((set) => (
|
||||
<section key={set.variant} style={{ display: "grid", gap: 16 }}>
|
||||
<h3 style={{ margin: 0, textTransform: "capitalize" }}>
|
||||
{set.variant}
|
||||
</h3>
|
||||
<Row title="mark" items={set.mark} />
|
||||
<Row title="wordmark" items={set.wordmark} />
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
Before Width: | Height: | Size: 406 KiB After Width: | Height: | Size: 406 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 253 B After Width: | Height: | Size: 253 B |
|
Before Width: | Height: | Size: 253 B After Width: | Height: | Size: 253 B |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 339 B After Width: | Height: | Size: 339 B |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 8.0 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg width="92" height="100" viewBox="0 0 92 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 50L60 0V46.5L0 96.5V50Z" fill="#E6E6E6" fill-opacity="0.4"/>
|
||||
<path d="M32 53L92 3V49.5L32 99.5V53Z" fill="#E6E6E6" fill-opacity="0.7"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 253 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="92" height="100" viewBox="0 0 92 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 50L60 0V46.5L0 96.5V50Z" fill="#ACACAC" fill-opacity="0.3"/>
|
||||
<path d="M32 53L92 3V49.5L32 99.5V53Z" fill="#FC9999" fill-opacity="0.5"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 253 B |
|
Before Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |