Merge branch 'main' into feature/documentClassifierAgent

This commit is contained in:
EthanHealy01
2026-06-30 19:42:24 +01:00
committed by GitHub
89 changed files with 2752 additions and 2492 deletions
@@ -2,6 +2,7 @@ package stirling.software.proprietary.policy.controller;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -52,11 +53,15 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.model.PolicyRun;
import stirling.software.proprietary.policy.model.PolicyRunStatus;
import stirling.software.proprietary.policy.model.PolicyRunView;
import stirling.software.proprietary.policy.overview.PoliciesOverviewResponse;
import stirling.software.proprietary.policy.overview.PolicyOverviewService;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import stirling.software.proprietary.policy.source.SourceAccessGuard;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
import stirling.software.proprietary.policy.trigger.PolicyTrigger;
import stirling.software.proprietary.policy.trigger.PolicyTriggerManager;
import stirling.software.proprietary.policy.trigger.TriggerInfo;
/**
* Policy CRUD plus pipeline runs (stored or ad-hoc). Runs are async: returns a run id, poll {@code
@@ -80,6 +85,8 @@ public class PolicyController {
private final PolicyAccessGuard policyAccessGuard;
private final PolicyManagementAuthority policyManagementAuthority;
private final PolicyTriggerManager policyTriggerManager;
private final PolicyOverviewService policyOverviewService;
private final List<PolicyTrigger> policyTriggers;
private final ApplicationProperties applicationProperties;
private final TempFileManager tempFileManager;
private final JobOwnershipService jobOwnershipService;
@@ -287,6 +294,31 @@ public class PolicyController {
return policyAccessGuard.visibleFrom(policyStore);
}
@GetMapping("/overview")
@Operation(
summary = "Pipelines overview",
description =
"Returns the KPI strip plus one row per policy the caller's team owns, each with"
+ " its referenced sources resolved to names, its pipeline steps, and a"
+ " trigger/output summary. Backs the portal's all-pipelines surface.")
public PoliciesOverviewResponse overview() {
return policyOverviewService.overview();
}
@GetMapping("/triggers")
@Operation(
summary = "List available triggers",
description =
"Lists each trigger kind with whether it needs a source and which source types"
+ " it supports, so the UI can offer triggers and pair them with the"
+ " right sources.")
public List<TriggerInfo> triggers() {
return policyTriggers.stream()
.map(TriggerInfo::of)
.sorted(Comparator.comparing(TriggerInfo::type))
.toList();
}
@GetMapping("/{policyId}")
@Operation(summary = "Get a policy by id")
public ResponseEntity<Policy> getPolicy(@PathVariable String policyId) {
@@ -337,6 +369,26 @@ public class PolicyController {
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
}
@PostMapping("/{policyId}/trigger")
@Operation(
summary = "Run a stored policy against its sources",
description =
"Pulls the policy's configured sources and runs the pipeline now, regardless of"
+ " the enabled flag (which only gates automatic triggering). Returns"
+ " the ids of the runs started; poll the run-status endpoint for each."
+ " Empty when the sources yielded no work to do.")
public ResponseEntity<List<String>> trigger(@PathVariable String policyId) {
Policy policy =
policyStore
.get(policyId)
.filter(policyAccessGuard::canAccess)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.NOT_FOUND, "No policy: " + policyId));
return ResponseEntity.accepted().body(policyRunner.run(policy));
}
private static void requireRunnable(PipelineDefinition definition) {
if (definition.steps().isEmpty()) {
throw new ResponseStatusException(
@@ -1,6 +1,7 @@
package stirling.software.proprietary.policy.engine;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
@@ -41,14 +42,15 @@ public class PolicyRunner {
* Trigger entry point. Pulls every referenced source; each yielded unit becomes its own run so
* one failure does not affect the others. No sources means one run with no input (generator
* pipeline). Missing or disabled sources are skipped so one broken reference does not stop the
* rest.
* rest. Returns the ids of the runs it started (empty when sources yielded no work), so a
* manual trigger can report back which runs to follow.
*/
public void run(Policy policy) {
public List<String> run(Policy policy) {
List<String> sourceIds = policy.sourceIds();
if (sourceIds.isEmpty()) {
startRun(policy, PolicyInputs.of(List.of()), unused -> {});
return;
return List.of(startRun(policy, PolicyInputs.of(List.of()), unused -> {}));
}
List<String> runIds = new ArrayList<>();
for (String sourceId : sourceIds) {
Source source = sourceStore.get(sourceId).orElse(null);
if (source == null) {
@@ -63,8 +65,9 @@ public class PolicyRunner {
policy.id());
continue;
}
pullAndRun(policy, source.toInputSpec());
runIds.addAll(pullAndRun(policy, source.toInputSpec()));
}
return runIds;
}
/** Run a stored policy on caller-supplied files (e.g. manual upload), bypassing its sources. */
@@ -79,14 +82,14 @@ public class PolicyRunner {
return policyEngine.submit(definition, inputs, listener);
}
private void pullAndRun(Policy policy, InputSpec spec) {
private List<String> pullAndRun(Policy policy, InputSpec spec) {
InputSource source = sourceFor(spec);
if (source == null) {
log.warn(
"No input source for type '{}' (policy {}); skipping",
spec.type(),
policy.id());
return;
return List.of();
}
List<ResolvedInput> work;
try {
@@ -97,19 +100,22 @@ public class PolicyRunner {
spec.type(),
policy.id(),
e.getMessage());
return;
return List.of();
}
List<String> runIds = new ArrayList<>();
for (ResolvedInput unit : work) {
startRun(policy, unit.inputs(), unit.onComplete());
runIds.add(startRun(policy, unit.inputs(), unit.onComplete()));
}
return runIds;
}
private void startRun(Policy policy, PolicyInputs inputs, Consumer<Boolean> onComplete) {
private String startRun(Policy policy, PolicyInputs inputs, Consumer<Boolean> onComplete) {
log.info("Running policy {} ({})", policy.id(), policy.name());
PolicyRunHandle handle =
policyEngine.runPolicy(policy, inputs, PolicyProgressListener.NOOP);
handle.completion()
.whenComplete((run, throwable) -> onComplete.accept(succeeded(run, throwable)));
return handle.runId();
}
private static boolean succeeded(PolicyRun run, Throwable throwable) {
@@ -0,0 +1,6 @@
package stirling.software.proprietary.policy.overview;
import java.util.List;
/** The Pipelines overview payload: a KPI strip plus one row per policy. */
public record PoliciesOverviewResponse(List<PolicyKpi> kpis, List<PolicyView> pipelines) {}
@@ -0,0 +1,4 @@
package stirling.software.proprietary.policy.overview;
/** One headline figure in the Pipelines overview strip. */
public record PolicyKpi(long value, String description) {}
@@ -0,0 +1,102 @@
package stirling.software.proprietary.policy.overview;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.TriggerConfig;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceAccessGuard;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
* Builds the Pipelines overview: every policy the caller's team owns, each annotated with its
* referenced sources (resolved to display names), its pipeline steps, and a trigger/output summary.
* Source names are resolved from the team's sources in memory rather than persisted on the policy,
* so the view always reflects the live source set. This is the "all pipelines" admin surface; the
* user-facing Policies page builds only a friendly subset of the same backend policies.
*/
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyOverviewService {
private final PolicyStore policyStore;
private final SourceStore sourceStore;
private final PolicyAccessGuard policyAccessGuard;
private final SourceAccessGuard sourceAccessGuard;
public PoliciesOverviewResponse overview() {
List<Policy> policies = policyAccessGuard.visibleFrom(policyStore);
Map<String, String> sourceNames = sourceNames();
List<PolicyView> views =
policies.stream()
.map(policy -> toView(policy, sourceNames))
.sorted(
Comparator.comparing(
PolicyView::name, String.CASE_INSENSITIVE_ORDER))
.toList();
return new PoliciesOverviewResponse(buildKpis(policies), views);
}
/** Display names for every source the caller's team can see, keyed by source id. */
private Map<String, String> sourceNames() {
Map<String, String> names = new HashMap<>();
for (Source source : sourceAccessGuard.visibleFrom(sourceStore)) {
names.put(source.id(), source.name());
}
return names;
}
private static PolicyView toView(Policy policy, Map<String, String> sourceNames) {
List<PolicyView.SourceRef> sources =
policy.sourceIds().stream()
// An unresolved id (source deleted, or not visible) falls back to the id so
// the row still renders rather than dropping the reference silently.
.map(id -> new PolicyView.SourceRef(id, sourceNames.getOrDefault(id, id)))
.toList();
List<String> steps = policy.steps().stream().map(PipelineStep::operation).toList();
return new PolicyView(
policy.id(),
policy.name(),
policy.enabled(),
policy.enabled() ? "active" : "paused",
triggerSummary(policy.trigger()),
sources,
steps,
outputSummary(policy.output()),
policy.owner());
}
/** A null trigger is a manual-only policy; otherwise the trigger's type keys the summary. */
private static String triggerSummary(TriggerConfig trigger) {
return trigger == null ? "manual" : trigger.type();
}
private static String outputSummary(OutputSpec output) {
return output == null ? "inline" : output.type();
}
private static List<PolicyKpi> buildKpis(List<Policy> policies) {
long total = policies.size();
long active = policies.stream().filter(Policy::enabled).count();
long paused = total - active;
return List.of(
new PolicyKpi(total, "pipelines"),
new PolicyKpi(active, "running automatically"),
new PolicyKpi(paused, "paused"));
}
}
@@ -0,0 +1,24 @@
package stirling.software.proprietary.policy.overview;
import java.util.List;
/**
* One row in the Pipelines overview: a stored policy shown for the admin portal, with its
* referenced sources resolved to names and its pipeline summarised. The portal's "all pipelines"
* surface lists every backend policy (the user-facing Policies page builds only a friendly subset
* of these).
*/
public record PolicyView(
String id,
String name,
boolean enabled,
String status,
String trigger,
List<SourceRef> sources,
List<String> steps,
String output,
String owner) {
/** A source a policy pulls documents from, resolved to its display name. */
public record SourceRef(String id, String name) {}
}
@@ -27,6 +27,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.config.FolderAccessGuard;
import stirling.software.proprietary.policy.engine.PolicyRunner;
import stirling.software.proprietary.policy.input.InputSource;
import stirling.software.proprietary.policy.model.InputSpec;
@@ -74,6 +75,16 @@ public class FolderWatchTrigger implements PolicyTrigger {
return TYPE;
}
@Override
public boolean requiresSource() {
return true;
}
@Override
public Set<String> supportedSourceTypes() {
return Set.of(FolderAccessGuard.FOLDER_TYPE);
}
@Override
public void validate(Policy policy) {
if (watchDirsOf(policy).isEmpty()) {
@@ -1,5 +1,7 @@
package stirling.software.proprietary.policy.trigger;
import java.util.Set;
import stirling.software.proprietary.policy.model.Policy;
/**
@@ -11,6 +13,24 @@ public interface PolicyTrigger {
/** Matches {@code TriggerConfig.type()}. */
String type();
/**
* Whether this trigger needs at least one compatible input source to function. A schedule fires
* on the clock regardless of sources, so it is false; folder-watch derives the directories it
* watches from the policy's sources, so it is true. Drives whether the UI offers the trigger.
*/
default boolean requiresSource() {
return false;
}
/**
* The source {@code type()}s this trigger is compatible with (e.g. {@code "folder"}). Empty
* means source-agnostic (no constraint). Lets the UI offer a trigger only when a compatible
* source is selected, without hard-coding the relationship.
*/
default Set<String> supportedSourceTypes() {
return Set.of();
}
/**
* Validate at save time so misconfiguration fails fast, not at fire time. Receives the whole
* {@link Policy} so triggers that depend on the policy's sources (folder-watch) can check that.
@@ -0,0 +1,18 @@
package stirling.software.proprietary.policy.trigger;
import java.util.List;
/**
* Describes an available trigger for the admin UI: its {@code type} (matching {@code
* TriggerConfig.type()}), whether it needs a compatible source, and which source types it works
* with. Lets the UI list supported triggers and pair them with sources without hard-coding the set.
*/
public record TriggerInfo(String type, boolean requiresSource, List<String> supportedSourceTypes) {
public static TriggerInfo of(PolicyTrigger trigger) {
return new TriggerInfo(
trigger.type(),
trigger.requiresSource(),
List.copyOf(trigger.supportedSourceTypes()));
}
}
@@ -57,12 +57,23 @@ class PolicyControllerTest {
@Mock private PolicyAccessGuard policyAccessGuard;
@Mock private PolicyManagementAuthority policyManagementAuthority;
@Mock private PolicyTriggerManager policyTriggerManager;
@Mock
private stirling.software.proprietary.policy.overview.PolicyOverviewService
policyOverviewService;
@Mock private TempFileManager tempFileManager;
@Mock private JobOwnershipService jobOwnershipService;
private ApplicationProperties applicationProperties;
private PolicyController controller;
private final java.util.List<stirling.software.proprietary.policy.trigger.PolicyTrigger>
policyTriggers =
java.util.List.of(
trigger("schedule", false, java.util.Set.of()),
trigger("folder-watch", true, java.util.Set.of("folder")));
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
@@ -77,11 +88,33 @@ class PolicyControllerTest {
policyAccessGuard,
policyManagementAuthority,
policyTriggerManager,
policyOverviewService,
policyTriggers,
applicationProperties,
tempFileManager,
jobOwnershipService);
}
private static stirling.software.proprietary.policy.trigger.PolicyTrigger trigger(
String type, boolean requiresSource, java.util.Set<String> sourceTypes) {
return new stirling.software.proprietary.policy.trigger.PolicyTrigger() {
@Override
public String type() {
return type;
}
@Override
public boolean requiresSource() {
return requiresSource;
}
@Override
public java.util.Set<String> supportedSourceTypes() {
return sourceTypes;
}
};
}
private static PipelineDefinition definitionWithStep() {
return new PipelineDefinition(
"pipe", List.of(new PipelineStep("/api/v1/misc/compress-pdf", null)), null);
@@ -431,4 +464,50 @@ class PolicyControllerTest {
.isEqualTo(HttpStatus.NOT_FOUND));
}
}
@Nested
@DisplayName("triggers / trigger")
class Triggers {
@Test
@DisplayName("lists triggers sorted, with source compatibility")
void listsTriggers() {
List<stirling.software.proprietary.policy.trigger.TriggerInfo> infos =
controller.triggers();
assertThat(infos).extracting(t -> t.type()).containsExactly("folder-watch", "schedule");
stirling.software.proprietary.policy.trigger.TriggerInfo folderWatch = infos.get(0);
assertThat(folderWatch.requiresSource()).isTrue();
assertThat(folderWatch.supportedSourceTypes()).containsExactly("folder");
assertThat(infos.get(1).requiresSource()).isFalse();
assertThat(infos.get(1).supportedSourceTypes()).isEmpty();
}
@Test
@DisplayName("trigger runs an accessible policy against its sources and returns run ids")
void triggersRun() {
Policy p = policy("a", 1L);
when(policyStore.get("a")).thenReturn(Optional.of(p));
when(policyAccessGuard.canAccess(p)).thenReturn(true);
when(policyRunner.run(p)).thenReturn(List.of("run-a", "run-b"));
ResponseEntity<List<String>> response = controller.trigger("a");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
assertThat(response.getBody()).containsExactly("run-a", "run-b");
}
@Test
@DisplayName("trigger is 404 when the policy is inaccessible")
void triggerNotFound() {
when(policyStore.get("z")).thenReturn(Optional.empty());
assertThatThrownBy(() -> controller.trigger("z"))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
e ->
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.NOT_FOUND));
}
}
}
@@ -0,0 +1,186 @@
package stirling.software.proprietary.policy.overview;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.TriggerConfig;
import stirling.software.proprietary.policy.source.InProcessSourceStore;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceAccessGuard;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.InProcessPolicyStore;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
* Tests for {@link PolicyOverviewService}: every policy appears once with its sources resolved to
* names, its steps and trigger/output summarised, and the KPI strip counting active vs paused.
* Login is disabled so the team guards pass everything through.
*/
class PolicyOverviewServiceTest {
private final SourceStore sourceStore = new InProcessSourceStore();
private final PolicyStore policyStore = new InProcessPolicyStore();
private PolicyOverviewService service;
@BeforeEach
void setUp() {
ApplicationProperties properties = new ApplicationProperties();
properties.getSecurity().setEnableLogin(false);
UserServiceInterface userService = mock(UserServiceInterface.class);
PolicyManagementAuthority authority = mock(PolicyManagementAuthority.class);
SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority);
PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority);
service = new PolicyOverviewService(policyStore, sourceStore, policyGuard, sourceGuard);
}
@Test
void eachPolicyAppearsWithResolvedSourcesStepsAndSummary() {
Source claims = source("Claims intake", "/claims");
policyStore.save(
new Policy(
null,
"Redaction",
"owner",
true,
new TriggerConfig("schedule", Map.of()),
List.of(claims.id()),
List.of(new PipelineStep("/api/v1/security/auto-redact", Map.of())),
OutputSpec.inline()));
policyStore.save(
new Policy(
null,
"Archive (paused)",
"owner",
false,
null,
List.of(),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline()));
PoliciesOverviewResponse response = service.overview();
assertEquals(2, response.pipelines().size());
// Sorted by name, case-insensitive, so "Archive" leads "Redaction".
PolicyView archive = response.pipelines().get(0);
assertEquals("Archive (paused)", archive.name());
assertEquals("paused", archive.status());
assertEquals("manual", archive.trigger());
PolicyView redaction = find(response, "Redaction");
assertEquals("active", redaction.status());
assertEquals("schedule", redaction.trigger());
assertEquals("inline", redaction.output());
assertEquals(List.of("/api/v1/security/auto-redact"), redaction.steps());
assertEquals(1, redaction.sources().size());
assertEquals(claims.id(), redaction.sources().get(0).id());
assertEquals("Claims intake", redaction.sources().get(0).name());
// KPI strip: total, active, paused.
assertEquals(List.of(2L, 1L, 1L), response.kpis().stream().map(PolicyKpi::value).toList());
}
@Test
void anUnresolvedSourceFallsBackToItsId() {
policyStore.save(
new Policy(
null,
"Orphan",
"owner",
true,
null,
List.of("src-missing"),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline()));
PolicyView view = find(service.overview(), "Orphan");
assertEquals(1, view.sources().size());
assertEquals("src-missing", view.sources().get(0).id());
assertEquals("src-missing", view.sources().get(0).name());
}
@Test
void overviewLoadsOnlyTheCallersTeam() {
ApplicationProperties properties = new ApplicationProperties();
properties.getSecurity().setEnableLogin(true);
UserServiceInterface userService = mock(UserServiceInterface.class);
PolicyManagementAuthority authority = mock(PolicyManagementAuthority.class);
when(authority.currentUserTeamId()).thenReturn(1L);
SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority);
PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority);
PolicyOverviewService scoped =
new PolicyOverviewService(policyStore, sourceStore, policyGuard, sourceGuard);
Source ours = teamSource("Ours", "/ours", 1L);
teamPolicy("Our policy", 1L, ours.id());
teamPolicy("Their policy", 2L, ours.id());
PoliciesOverviewResponse response = scoped.overview();
assertEquals(1, response.pipelines().size());
PolicyView view = response.pipelines().get(0);
assertEquals("Our policy", view.name());
assertEquals("Ours", view.sources().get(0).name());
assertEquals(List.of(1L, 1L, 0L), response.kpis().stream().map(PolicyKpi::value).toList());
}
@Test
void emptyStoreReportsZeroKpis() {
PoliciesOverviewResponse response = service.overview();
assertTrue(response.pipelines().isEmpty());
assertEquals(List.of(0L, 0L, 0L), response.kpis().stream().map(PolicyKpi::value).toList());
}
private Source source(String name, String directory) {
return sourceStore.save(
new Source(
null, name, "folder", Map.of("directory", directory), true, "owner", null));
}
private Source teamSource(String name, String directory, Long teamId) {
return sourceStore.save(
new Source(
null,
name,
"folder",
Map.of("directory", directory),
true,
"owner",
teamId));
}
private void teamPolicy(String name, Long teamId, String... sourceIds) {
policyStore.save(
new Policy(
null,
name,
"owner",
true,
null,
List.of(sourceIds),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline(),
teamId));
}
private static PolicyView find(PoliciesOverviewResponse response, String name) {
return response.pipelines().stream()
.filter(view -> view.name().equals(name))
.findFirst()
.orElseThrow();
}
}
@@ -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"
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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();
+11
View File
@@ -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"
+160 -29
View File
@@ -1,39 +1,170 @@
import { apiClient } from "@portal/api/http";
import type { PipelinesResponse } from "@portal/mocks/pipelines";
import type { Tier } from "@portal/contexts/TierContext";
export type {
EvalsNote,
GoldenSet,
Pipeline,
PipelineMetrics,
PipelinesResponse,
PipelineStatus,
PromotedPipeline,
PromotedStatus,
SchemaDrift,
StageKey,
StageSummary,
} from "@portal/mocks/pipelines";
/**
* Pipelines service layer: the backend contract.
*
* A "pipeline" in the portal IS a backend policy (PolicyController, Policy.java):
* an ordered chain of tool steps with input sources, a trigger, and an output
* destination. This surface lists EVERY backend policy (the user-facing Policies
* page builds only a friendly subset of the same records). Like Sources, it calls
* the REAL Stirling API base `/api/v1/policies`, so dropping MSW points these exact
* calls at the live backend.
*/
/** GET /v1/pipelines?tier=… — the deployed fleet plus tier-specific extras. */
export async function fetchPipelines(tier: Tier): Promise<PipelinesResponse> {
return apiClient.local.json<PipelinesResponse>(
`/v1/pipelines?tier=${encodeURIComponent(tier)}`,
);
/** One tool invocation in a pipeline. `operation` is a Stirling endpoint path. */
export interface PipelineStep {
operation: string;
parameters: Record<string, unknown>;
fileParameters?: Record<string, string>;
}
/** When a policy fires automatically. `type` keys a trigger bean (e.g. "schedule"). */
export interface TriggerConfig {
type: string;
options: Record<string, unknown>;
}
/** Where a run's outputs are delivered. `type` keys an output sink (e.g. "inline"). */
export interface OutputSpec {
type: string;
options: Record<string, unknown>;
}
/**
* Promote a watch-folder-derived pipeline into a governed org policy, so its
* rules apply fleet-wide instead of just to the originating flow.
*
* TODO(backend): POST /v1/pipelines/{id}/promote-to-policy — should create the
* policy from the pipeline's stages and return the new policy id. The mock
* handler resolves `{ ok: true }`; the UI treats a resolved promise as accepted.
* The stored policy record: the create/update body (`id` blank on create) and what
* the backend returns from GET/POST. Mirrors Policy.java exactly; `owner`/`teamId`
* are stamped server-side. A `null` trigger means manual-only.
*/
export async function promoteToPolicy(id: string): Promise<{ ok: true }> {
return apiClient.local.json<{ ok: true }>(
`/v1/pipelines/${encodeURIComponent(id)}/promote-to-policy`,
export interface Policy {
id?: string;
name: string;
owner?: string | null;
enabled: boolean;
trigger: TriggerConfig | null;
sourceIds: string[];
steps: PipelineStep[];
output: OutputSpec;
teamId?: number | null;
}
/** Overview row status: enabled (fires automatically) or paused. */
export type PipelineStatus = "active" | "paused";
/** A source a pipeline pulls documents from, resolved to its display name. */
export interface PipelineSourceRef {
id: string;
name: string;
}
/** One row in the Pipelines overview. Mirrors the backend `PolicyView`. */
export interface PipelineView {
id: string;
name: string;
enabled: boolean;
status: PipelineStatus;
/** Trigger summary: "manual" or the trigger type (e.g. "schedule"). */
trigger: string;
sources: PipelineSourceRef[];
/** Operation endpoint paths, in run order. */
steps: string[];
/** Output sink type (e.g. "inline", "folder"). */
output: string;
owner: string;
}
export interface PipelineKpi {
value: number;
description: string;
}
export interface PipelinesOverviewResponse {
kpis: PipelineKpi[];
pipelines: PipelineView[];
}
/** A trigger kind and the source types it works with. Mirrors the backend `TriggerInfo`. */
export interface TriggerInfo {
/** Matches `TriggerConfig.type` (e.g. "schedule", "folder-watch"). */
type: string;
/** Whether the trigger needs at least one compatible source to function. */
requiresSource: boolean;
/** Source types it supports; empty means source-agnostic (no constraint). */
supportedSourceTypes: string[];
}
export type PolicyRunStatus =
| "PENDING"
| "RUNNING"
| "WAITING_FOR_INPUT"
| "COMPLETED"
| "FAILED"
| "CANCELLED";
/** A run's current state. Mirrors the backend `PolicyRunView` (outputs elided). */
export interface PolicyRunView {
runId: string;
policyId: string | null;
status: PolicyRunStatus;
currentStep: number;
stepCount: number;
/** Human-readable failure message; set when status is FAILED. */
error: string | null;
errorCode: string | null;
createdAt: number;
}
/** GET /api/v1/policies/overview: KPI strip + one row per policy for the admin. */
export async function fetchPipelines(): Promise<PipelinesOverviewResponse> {
return apiClient.local.json<PipelinesOverviewResponse>(
"/api/v1/policies/overview",
);
}
/** GET /api/v1/policies/{id}: the raw policy record (steps, sources, trigger), for editing. */
export async function fetchPipeline(id: string): Promise<Policy> {
return apiClient.local.json<Policy>(
`/api/v1/policies/${encodeURIComponent(id)}`,
);
}
/** POST /api/v1/policies: create (blank id) or update (matched id) a policy. */
export async function savePipeline(policy: Policy): Promise<Policy> {
return apiClient.local.json<Policy>("/api/v1/policies", {
method: "POST",
body: policy,
});
}
/** DELETE /api/v1/policies/{id}: remove a policy. */
export async function deletePipeline(id: string): Promise<void> {
await apiClient.local.json<void>(
`/api/v1/policies/${encodeURIComponent(id)}`,
{
method: "DELETE",
},
);
}
/** GET /api/v1/policies/triggers: available triggers + their source compatibility. */
export async function fetchTriggers(): Promise<TriggerInfo[]> {
return apiClient.local.json<TriggerInfo[]>("/api/v1/policies/triggers");
}
/**
* POST /api/v1/policies/{id}/trigger: run the pipeline now against its configured
* sources, regardless of the enabled flag. Returns the ids of the runs started
* (empty when the sources yielded no work); poll {@link fetchRun} for each.
*/
export async function triggerPipeline(id: string): Promise<string[]> {
return apiClient.local.json<string[]>(
`/api/v1/policies/${encodeURIComponent(id)}/trigger`,
{ method: "POST" },
);
}
/** GET /api/v1/policies/run/{runId}: current status, error, and step cursor of a run. */
export async function fetchRun(runId: string): Promise<PolicyRunView> {
return apiClient.local.json<PolicyRunView>(
`/api/v1/policies/run/${encodeURIComponent(runId)}`,
);
}
@@ -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
+2 -2
View File
@@ -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",
},
];
+203 -11
View File
@@ -1,20 +1,212 @@
import { http, HttpResponse, delay } from "msw";
import type { Tier } from "@portal/contexts/TierContext";
import { pipelinesFor } from "@portal/mocks/pipelines";
import type {
PipelineKpi,
PipelineStatus,
PipelineView,
PipelinesOverviewResponse,
Policy,
} from "@portal/api/pipelines";
/**
* Stateful mock for the Pipelines surface so the portal works fully offline with
* mocks on. Mirrors the real backend shape (`/api/v1/policies`, PolicyController +
* PolicyOverviewService): the overview, create, edit, and delete mutate an
* in-memory store of real-shaped policies. With mocks OFF these calls fall through
* to the real backend instead.
*
* The user-facing Policies "catalogue" page also lives on `/api/v1/policies` (its
* own handlers, a different response shape). These handlers are registered first
* and DISCRIMINATE: anything that isn't a real-shaped pipeline (the catalogue
* page's bodies carry a `categoryId`; its ids aren't in this store) is passed
* through by returning nothing, so the catalogue handlers still serve it. That
* keeps both surfaces working in mock mode without either clobbering the other.
*/
/** Display names for the seeded sources, so the overview resolves ids to names. */
const SOURCE_NAMES: Record<string, string> = {
"src-claims": "Claims intake",
"src-contracts": "Contracts drop",
"src-archive": "Archive reprocess",
};
interface StoredPolicy extends Policy {
id: string;
}
function seedPipelines(): StoredPolicy[] {
return [
{
id: "plc-redaction",
name: "Redaction sweep",
owner: "security@acme.com",
enabled: true,
trigger: {
type: "schedule",
options: { schedule: { type: "every", count: 6, unit: "HOURS" } },
},
sourceIds: ["src-claims"],
steps: [
{
operation: "/api/v1/security/auto-redact",
parameters: { mode: "automatic", convertPDFToImage: true },
},
{ operation: "/api/v1/security/sanitize-pdf", parameters: {} },
],
output: { type: "inline", options: {} },
},
{
id: "plc-archive",
name: "Archive compressor",
owner: "data-eng@acme.com",
enabled: true,
trigger: null,
sourceIds: ["src-contracts", "src-archive"],
steps: [{ operation: "/api/v1/misc/compress-pdf", parameters: {} }],
output: { type: "folder", options: { directory: "/data/archive-out" } },
},
{
id: "plc-onboarding",
name: "Onboarding OCR (paused)",
owner: "ops@acme.com",
enabled: false,
trigger: null,
sourceIds: [],
steps: [
{ operation: "/api/v1/misc/ocr-pdf", parameters: {} },
{ operation: "/api/v1/misc/flatten", parameters: {} },
],
output: { type: "inline", options: {} },
},
];
}
let store: StoredPolicy[] = seedPipelines();
let idCounter = 0;
function nextId(): string {
idCounter += 1;
return `plc_${Date.now().toString(36)}_${idCounter}`;
}
function deriveStatus(policy: StoredPolicy): PipelineStatus {
return policy.enabled ? "active" : "paused";
}
function toView(policy: StoredPolicy): PipelineView {
return {
id: policy.id,
name: policy.name,
enabled: policy.enabled,
status: deriveStatus(policy),
trigger: policy.trigger?.type ?? "manual",
sources: policy.sourceIds.map((id) => ({
id,
name: SOURCE_NAMES[id] ?? id,
})),
steps: policy.steps.map((s) => s.operation),
output: policy.output?.type ?? "inline",
owner: policy.owner ?? "you@acme.com",
};
}
function buildKpis(): PipelineKpi[] {
const total = store.length;
const active = store.filter((p) => p.enabled).length;
return [
{ value: total, description: "pipelines" },
{ value: active, description: "running automatically" },
{ value: total - active, description: "paused" },
];
}
function buildOverview(): PipelinesOverviewResponse {
const pipelines = store
.map(toView)
.sort((a, b) => a.name.localeCompare(b.name));
return { kpis: buildKpis(), pipelines };
}
export const pipelinesHandlers = [
http.get("/v1/pipelines", async ({ request }) => {
http.get("/api/v1/policies/overview", async () => {
await delay(120);
const url = new URL(request.url);
const tier = (url.searchParams.get("tier") ?? "pro") as Tier;
return HttpResponse.json(pipelinesFor(tier));
return HttpResponse.json(buildOverview());
}),
// Accepts the promote-to-policy submit so the UI can resolve. The real
// backend would create a policy from the pipeline's stages; here it just
// acknowledges. See TODO(backend) on api/pipelines.ts promoteToPolicy.
http.post("/v1/pipelines/:id/promote-to-policy", async () => {
// Available triggers + their source-type compatibility. Registered before the
// ":id" handler so "triggers" isn't matched as a policy id.
http.get("/api/v1/policies/triggers", async () => {
await delay(120);
return HttpResponse.json({ ok: true });
return HttpResponse.json([
{ type: "schedule", requiresSource: false, supportedSourceTypes: [] },
{
type: "folder-watch",
requiresSource: true,
supportedSourceTypes: ["folder"],
},
]);
}),
// Run status: the mock completes runs immediately, so polling resolves at once.
http.get("/api/v1/policies/run/:runId", async ({ params }) => {
await delay(120);
return HttpResponse.json({
runId: String(params.runId),
policyId: null,
status: "COMPLETED",
currentStep: 1,
stepCount: 1,
error: null,
errorCode: null,
createdAt: Date.now(),
});
}),
// Manual trigger: pretends to start one run and returns its id to poll.
http.post("/api/v1/policies/:id/trigger", async ({ params }) => {
if (!store.some((p) => p.id === params.id)) return undefined;
await delay(120);
return HttpResponse.json([`run_${Date.now().toString(36)}`]);
}),
// Raw policy by id. Only our pipeline ids are served here; everything else falls
// through to the catalogue page's handler.
http.get("/api/v1/policies/:id", async ({ params }) => {
const policy = store.find((p) => p.id === params.id);
if (!policy) return undefined;
await delay(120);
return HttpResponse.json(policy);
}),
// Create or update a pipeline. The catalogue page's bodies carry a `categoryId`;
// those are passed through so its own handler stores them.
http.post("/api/v1/policies", async ({ request }) => {
// Clone before reading: a non-pipeline body falls through to the catalogue
// page's handler, which needs to read the (still-unconsumed) body itself.
const incoming = (await request.clone().json()) as Policy & {
categoryId?: string;
};
if ("categoryId" in incoming) return undefined;
await delay(120);
const existing = incoming.id
? store.find((p) => p.id === incoming.id)
: undefined;
const id = existing?.id ?? nextId();
const saved: StoredPolicy = {
...incoming,
id,
owner: existing?.owner ?? "you@acme.com",
};
store = existing
? store.map((p) => (p.id === id ? saved : p))
: [...store, saved];
return HttpResponse.json(saved);
}),
http.delete("/api/v1/policies/:id", async ({ params }) => {
const id = String(params.id);
if (!store.some((p) => p.id === id)) return undefined;
await delay(120);
store = store.filter((p) => p.id !== id);
return new HttpResponse(null, { status: 204 });
}),
];
-415
View File
@@ -1,415 +0,0 @@
/**
* Pipelines fixtures and the types api/pipelines.ts shares with them.
* api/pipelines.ts imports the types; the MSW handlers in mocks/handlers/
* serve the fixture data over the intercepted apiClient.local.json() calls. Components
* never reach into this module directly.
*
* Fixtures are tier-shaped:
* - free empty (prompts "build your first pipeline")
* - pro a small deployed fleet
* - enterprise a larger fleet plus a shadow/comparative evals note
*/
import type { Tier } from "@portal/contexts/TierContext";
/** A deployed pipeline's live health. */
export type PipelineStatus = "healthy" | "degraded";
/** 24-hour rollup shown on the pipeline row. */
export interface PipelineMetrics {
/** Docs processed in the trailing 24h. */
docs24h: number;
/** Sustained throughput, docs/min. */
throughputPerMin: number;
/** Error rate as a fraction (0.004 = 0.4%). */
errorRate: number;
/** P95 stage-to-store latency, ms. */
p95LatencyMs: number;
/** Uptime over the trailing 24h, as a fraction. */
uptime: number;
}
/**
* The five silent stages every pipeline passes a document through. The accent
* is fixed per stage so the chip row reads the same across every pipeline:
* Ingest=green, Validate=blue, Modify=amber, Secure=red, Route/Store=purple.
*/
export type StageKey = "ingest" | "validate" | "modify" | "secure" | "route";
export interface StageSummary {
key: StageKey;
label: string;
/** Op labels active in this stage for this pipeline. */
ops: string[];
}
/** Golden-set validation rollup. */
export interface GoldenSet {
passing: number;
total: number;
/** Last time the set was run. */
lastRun: string;
/**
* Minimum pass rate (fraction) this pipeline must hold to be considered
* reliable. A pipeline below its own bound is amber/red at a glance the
* bound is per-pipeline because a clause-risk pipeline tolerates less slack
* than a high-volume extraction one.
*/
threshold: number;
}
/** A single field whose shape has drifted from the inferred schema. */
export interface SchemaDrift {
field: string;
/** Human summary of what changed. */
note: string;
/** Confidence delta since the last known-good shape (negative = worse). */
confidenceDelta: number;
severity: "info" | "warning";
/** Share of docs in the window that exhibited the drift. */
affectedDocs: number;
}
export interface Pipeline {
id: string;
name: string;
/** What the pipeline does, one line. */
blurb: string;
status: PipelineStatus;
/** Source rail label (from SOURCE_OPTIONS). */
source: string;
/** Destination rail label (from DESTINATION_OPTIONS). */
destination: string;
/** Deployed version tag. */
version: string;
/** Regions the pipeline runs in. */
regions: string[];
metrics: PipelineMetrics;
stages: StageSummary[];
golden: GoldenSet;
drift: SchemaDrift[];
}
const STAGE_LABEL: Record<StageKey, string> = {
ingest: "Ingest",
validate: "Validate",
modify: "Modify",
secure: "Secure",
route: "Route / Store",
};
/** Build a five-stage summary from the per-stage op-label lists. */
function stages(
ingest: string[],
validate: string[],
modify: string[],
secure: string[],
route: string[],
): StageSummary[] {
const byKey: Record<StageKey, string[]> = {
ingest,
validate,
modify,
secure,
route,
};
return (Object.keys(byKey) as StageKey[]).map((key) => ({
key,
label: STAGE_LABEL[key],
ops: byKey[key],
}));
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Tier fixtures */
/* ──────────────────────────────────────────────────────────────────────── */
const COI_COMPLIANCE: Pipeline = {
id: "pl-coi",
name: "COI Compliance",
blurb: "Certificate-of-insurance intake → coverage-gap check → vault",
status: "healthy",
source: "Email intake",
destination: "Stirling vault",
version: "v3.4.1",
regions: ["us-east-1", "eu-west-1"],
metrics: {
docs24h: 28941,
throughputPerMin: 22,
errorRate: 0.004,
p95LatencyMs: 412,
uptime: 0.9998,
},
stages: stages(
["OCR", "Classify", "Extract"],
["Schema validate", "Confidence bounds"],
["Compress"],
["Redact PII", "Encryption at rest"],
["Primary store", "Processing manifest"],
),
golden: { passing: 36, total: 36, lastRun: "2h ago", threshold: 0.95 },
drift: [],
};
const PRIOR_AUTH: Pipeline = {
id: "pl-prior-auth",
name: "Prior Auth Router",
blurb: "Prior-authorization intake → medical-necessity gate → payer webhook",
status: "degraded",
source: "Inbound webhook",
destination: "Outbound webhook",
version: "v3.1.0",
regions: ["us-east-1"],
metrics: {
docs24h: 11204,
throughputPerMin: 9,
errorRate: 0.031,
p95LatencyMs: 740,
uptime: 0.9962,
},
stages: stages(
["OCR", "Classify", "Extract"],
["Schema validate", "Counterparty match", "Confidence bounds"],
["Convert"],
["Redact PII", "PII/PHI enforcement", "Encryption at rest"],
["Conditional routing", "Human review"],
),
// Below its own 0.90 bound (24/28 ≈ 0.857) — the at-a-glance reliability miss.
golden: { passing: 24, total: 28, lastRun: "47m ago", threshold: 0.9 },
drift: [
{
field: "procedure_codes",
note: "New CPT modifier suffix not seen in prior examples",
confidenceDelta: -0.07,
severity: "warning",
affectedDocs: 18,
},
{
field: "payer",
note: "Two payers now emit a merged-entity name",
confidenceDelta: -0.03,
severity: "info",
affectedDocs: 6,
},
],
};
const INVOICE_AP: Pipeline = {
id: "pl-invoice-ap",
name: "Invoice → AP",
blurb: "Invoice extraction → three-way match → Postgres",
status: "healthy",
source: "S3 bucket watch",
destination: "Database",
version: "v2.8.0",
regions: ["us-east-1", "eu-west-1", "ap-southeast-1"],
metrics: {
docs24h: 53120,
throughputPerMin: 41,
errorRate: 0.006,
p95LatencyMs: 358,
uptime: 0.9997,
},
stages: stages(
["Parse", "Classify", "Extract"],
["Schema validate", "Confidence bounds"],
["PDF → CSV"],
["Redact PII", "Encryption at rest"],
["Primary store", "Mirror to bucket", "Notify"],
),
golden: { passing: 41, total: 42, lastRun: "1h ago", threshold: 0.95 },
drift: [
{
field: "tax",
note: "EU reverse-charge invoices omit a line-level tax field",
confidenceDelta: -0.02,
severity: "info",
affectedDocs: 4,
},
],
};
const CONTRACT_REVIEW: Pipeline = {
id: "pl-contract",
name: "Contract Review",
blurb: "Contract intake → clause-risk analysis → review queue",
status: "healthy",
source: "Upload API",
destination: "Another pipeline",
version: "v1.9.2",
regions: ["eu-west-1"],
metrics: {
docs24h: 4380,
throughputPerMin: 4,
errorRate: 0.009,
p95LatencyMs: 1280,
uptime: 0.9991,
},
stages: stages(
["OCR", "Classify"],
["Contract analyzer", "Authenticity", "Confidence bounds"],
["Document summarizer"],
["Redact PII", "Confidentiality mark", "Signed outputs"],
["Human review", "Flag"],
),
golden: { passing: 31, total: 33, lastRun: "5h ago", threshold: 0.95 },
drift: [],
};
const KYC_PROCESSOR: Pipeline = {
id: "pl-kyc",
name: "KYC Processor",
blurb:
"Identity-document intake → authenticity + sanctions → compliance archive",
status: "healthy",
source: "Scheduled import",
destination: "Compliance archive",
version: "v4.0.3",
regions: ["us-east-1", "eu-west-1", "ap-southeast-1"],
metrics: {
docs24h: 19870,
throughputPerMin: 15,
errorRate: 0.005,
p95LatencyMs: 503,
uptime: 0.9999,
},
stages: stages(
["OCR", "Classify", "Extract"],
["Authenticity", "Tamper check", "Counterparty match"],
["Convert"],
["Field-aware redact", "Attribution watermark", "Encryption at rest"],
["Compliance archive", "Processing manifest", "Notify"],
),
golden: { passing: 52, total: 54, lastRun: "31m ago", threshold: 0.95 },
drift: [
{
field: "document_number",
note: "New passport series uses a 9-char alphanumeric format",
confidenceDelta: -0.04,
severity: "warning",
affectedDocs: 11,
},
],
};
/**
* Enterprise-only evals note. Surfaced as a banner: shadow + comparative eval
* runs gate every promotion, so the deployed fleet always trails a quietly
* running candidate.
*/
export interface EvalsNote {
/** Pipelines currently running a shadow eval against production traffic. */
shadowCount: number;
/** Comparative (champion/challenger) runs awaiting sign-off. */
comparativeCount: number;
detail: string;
}
const ENTERPRISE_EVALS: EvalsNote = {
shadowCount: 2,
comparativeCount: 2,
detail:
"Prior Auth v3.2.0-rc and Invoice v2.9.0-rc are mirroring live traffic in shadow; KYC v4.1.0 is in a comparative run against v4.0.3. The Contract Review v2.0.0 candidate is blocked — it regressed 3 golden cases on clause-risk scoring, so the comparative run is held until the candidate is re-cut. No promotion happens until a candidate clears its golden set and the comparative delta stays inside bounds.",
};
/**
* A pipeline that began life as an Editor watch-folder flow and was promoted
* into the portal. These are the on-ramp from ad-hoc desktop automation to a
* governed, deployed pipeline they keep a pointer back to the watch folder
* they grew out of so the lineage stays visible.
*/
export interface PromotedPipeline {
id: string;
name: string;
/** Doc type the originating watch-folder flow was built around. */
sourceDocType: string;
/** The Editor watch folder this was promoted from. */
watchFolder: string;
/** Where the promotion sits in its lifecycle. */
status: PromotedStatus;
/** When the promotion landed. */
promotedAt: string;
}
/**
* A promoted flow is `deployed` once it runs in the portal, `staged` while it
* mirrors the watch folder without taking over, and `review` when it needs a
* human to confirm the flow before it goes live.
*/
export type PromotedStatus = "deployed" | "staged" | "review";
export interface PipelinesResponse {
pipelines: Pipeline[];
/** Present for enterprise only. */
evals: EvalsNote | null;
/** Flows promoted up from Editor watch folders. Empty on free. */
promoted: PromotedPipeline[];
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Promoted-from-Editor fixtures */
/* ──────────────────────────────────────────────────────────────────────── */
const PROMOTED_PRO: PromotedPipeline[] = [
{
id: "pl-promo-statements",
name: "Bank Statement Normalizer",
sourceDocType: "Bank statement",
watchFolder: "~/StirlingWatch/statements-in",
status: "deployed",
promotedAt: "promoted 3d ago",
},
{
id: "pl-promo-receipts",
name: "Receipt Splitter",
sourceDocType: "Expense receipt",
watchFolder: "~/StirlingWatch/receipts",
status: "staged",
promotedAt: "promoted 11h ago",
},
];
const PROMOTED_ENTERPRISE: PromotedPipeline[] = [
...PROMOTED_PRO,
{
id: "pl-promo-claims",
name: "Claims Intake Splitter",
sourceDocType: "Insurance claim",
watchFolder: "\\\\fileserver\\ClaimsDropbox",
status: "deployed",
promotedAt: "promoted 6d ago",
},
{
id: "pl-promo-onboarding",
name: "New-Hire Packet Sorter",
sourceDocType: "Onboarding packet",
watchFolder: "\\\\hr-share\\NewHireScans",
status: "review",
promotedAt: "promoted 2h ago",
},
];
export function pipelinesFor(tier: Tier): PipelinesResponse {
if (tier === "free") {
return { pipelines: [], evals: null, promoted: [] };
}
if (tier === "enterprise") {
return {
pipelines: [
INVOICE_AP,
COI_COMPLIANCE,
PRIOR_AUTH,
KYC_PROCESSOR,
CONTRACT_REVIEW,
],
evals: ENTERPRISE_EVALS,
promoted: PROMOTED_ENTERPRISE,
};
}
// pro
return {
pipelines: [COI_COMPLIANCE, INVOICE_AP, PRIOR_AUTH],
evals: null,
promoted: PROMOTED_PRO,
};
}
+235 -529
View File
@@ -8,7 +8,7 @@
}
/* Header */
.portal-pipelines__header {
.portal-pipelines__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
@@ -30,574 +30,280 @@
max-width: 46rem;
}
/* Fleet summary chips */
.portal-pipelines__fleet {
/* Table cells */
.portal-pipelines__name-cell {
display: flex;
align-items: center;
gap: 0.5rem;
}
.portal-pipelines__fleet-count {
font-size: 0.75rem;
color: var(--color-text-4);
}
/* List */
.portal-pipelines__list {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.portal-pipelines__card {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.portal-pipelines__card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.portal-pipelines__card-title {
margin: 0;
font-size: 1.0625rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-pipelines__card-blurb {
margin: 0.125rem 0 0;
font-size: 0.8125rem;
color: var(--color-text-3);
}
/* Source → stages → destination rail */
.portal-pipelines__card-rail {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.portal-pipelines__rail-chip {
font-size: 0.6875rem;
font-weight: 500;
color: var(--color-text-3);
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-pill);
padding: 0.1875rem 0.5rem;
}
.portal-pipelines__rail-arrow {
color: var(--color-text-5);
font-size: 0.75rem;
}
.portal-pipelines__stage-dots {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.portal-pipelines__stage-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
}
/* Metric strip on the card */
.portal-pipelines__metrics {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.5rem;
padding: 0.625rem 0;
border-top: 1px solid var(--color-border-light);
border-bottom: 1px solid var(--color-border-light);
}
@media (max-width: 50rem) {
.portal-pipelines__metrics {
grid-template-columns: repeat(2, 1fr);
}
}
.portal-pipelines__card-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
font-size: 0.6875rem;
color: var(--color-text-4);
}
.portal-pipelines__card-version {
font-family: var(--font-mono);
}
.portal-pipelines__card-golden {
color: var(--color-text-3);
}
.portal-pipelines__card-drift {
color: var(--color-amber-dark);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Detail drawer */
/* ──────────────────────────────────────────────────────────────────────── */
.portal-pipelines__detail {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.portal-pipelines__detail-metrics {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.625rem;
padding: 0.875rem;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
}
.portal-pipelines__detail-section {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.portal-pipelines__detail-h {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-pipelines__detail-sub {
margin: -0.375rem 0 0;
font-size: 0.75rem;
color: var(--color-text-4);
}
/* The five silent stages */
.portal-pipelines__stages {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.portal-pipelines__stage {
padding: 0.625rem 0.75rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.portal-pipelines__stage-head {
display: flex;
align-items: center;
gap: 0.4375rem;
margin-bottom: 0.5rem;
}
.portal-pipelines__stage-pip {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
}
.portal-pipelines__stage-name {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-2);
}
.portal-pipelines__stage-chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.portal-pipelines__stage-empty {
font-size: 0.75rem;
color: var(--color-text-5);
font-style: italic;
}
/* Golden set */
.portal-pipelines__golden {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
}
.portal-pipelines__golden-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.portal-pipelines__golden-when {
font-size: 0.6875rem;
color: var(--color-text-4);
}
/* Schema drift */
.portal-pipelines__drift-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.portal-pipelines__drift {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 0.75rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.portal-pipelines__drift-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
}
.portal-pipelines__drift-text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.portal-pipelines__drift-field {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-1);
font-family: var(--font-mono);
}
.portal-pipelines__drift-note {
font-size: 0.75rem;
color: var(--color-text-4);
}
.portal-pipelines__drift-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.125rem;
font-size: 0.6875rem;
color: var(--color-text-4);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Composer modal */
/* ──────────────────────────────────────────────────────────────────────── */
.portal-pipelines__composer {
min-height: 22rem;
}
.portal-pipelines__composer-body {
display: flex;
flex-direction: column;
gap: 1rem;
}
.portal-pipelines__composer-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.625rem;
}
@media (max-width: 44rem) {
.portal-pipelines__composer-grid {
grid-template-columns: 1fr;
}
}
.portal-pipelines__option {
.portal-pipelines__name-text {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem;
text-align: left;
background: var(--color-surface);
border: 1px solid var(--color-border);
min-width: 0;
}
.portal-pipelines__name-text strong {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-pipelines__pipe-dot {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.875rem;
height: 1.875rem;
flex-shrink: 0;
border-radius: var(--radius-md);
cursor: pointer;
transition:
border-color var(--motion-fast),
background var(--motion-fast);
}
.portal-pipelines__option:hover {
border-color: var(--color-border-hover);
background: var(--color-bg-hover);
}
.portal-pipelines__option.is-selected {
border-color: var(--color-blue);
font-size: 0.875rem;
background: var(--color-blue-light);
}
.portal-pipelines__option-label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-pipelines__option-desc {
font-size: 0.6875rem;
color: var(--color-text-4);
line-height: 1.4;
}
/* Op chain summary */
.portal-pipelines__chain {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
}
.portal-pipelines__chain-label,
.portal-pipelines__agents-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-3);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.portal-pipelines__chain-chips,
.portal-pipelines__agents-row {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.portal-pipelines__chain-empty {
font-size: 0.75rem;
color: var(--color-text-5);
font-style: italic;
}
.portal-pipelines__agents {
display: flex;
flex-direction: column;
gap: 0.4375rem;
}
/* Op library picker */
.portal-pipelines__library {
display: flex;
flex-direction: column;
gap: 0.875rem;
max-height: 19rem;
overflow-y: auto;
padding: 0.75rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.portal-pipelines__library-group {
display: flex;
flex-direction: column;
gap: 0.4375rem;
}
.portal-pipelines__library-head {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-2);
}
.portal-pipelines__library-pip {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
}
.portal-pipelines__library-chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
/* Routing alerts */
.portal-pipelines__alerts {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.portal-pipelines__alert {
display: grid;
grid-template-columns: auto 1fr;
align-items: start;
gap: 0.625rem;
padding: 0.625rem 0.75rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
}
.portal-pipelines__alert input {
margin-top: 0.1875rem;
accent-color: var(--color-blue);
}
.portal-pipelines__alert > span {
display: flex;
flex-direction: column;
gap: 0.0625rem;
}
.portal-pipelines__alert strong {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-pipelines__alert span span {
font-size: 0.6875rem;
color: var(--color-text-4);
}
/* Composer step indicator in the footer */
.portal-pipelines__composer-steps {
display: flex;
align-items: center;
gap: 0.625rem;
margin-right: auto;
}
.portal-pipelines__composer-step {
font-size: 0.6875rem;
font-weight: 500;
color: var(--color-text-5);
}
.portal-pipelines__composer-step.is-active {
color: var(--color-blue);
font-weight: 600;
}
.portal-pipelines__composer-step.is-done {
color: var(--color-green-dark);
.portal-pipelines__muted {
font-size: 0.8125rem;
color: var(--color-text-4);
margin: 0;
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Roster + promoted sections */
/* ──────────────────────────────────────────────────────────────────────── */
.portal-pipelines__caret {
display: inline-block;
color: var(--color-text-5);
transition: transform var(--motion-fast);
}
.portal-pipelines__section {
.portal-pipelines__caret.is-open {
transform: rotate(90deg);
color: var(--color-blue);
}
/* Expanded detail panel */
.portal-pipelines__expanded {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: 1.25rem;
animation: portal-pipelines-reveal var(--motion-fast) ease-out;
}
@keyframes portal-pipelines-reveal {
from {
opacity: 0;
transform: translateY(-0.25rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.portal-pipelines__expanded-head {
display: flex;
flex-direction: column;
gap: 0.625rem;
align-items: center;
gap: 0.75rem;
padding-bottom: 0.875rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--color-border-light);
}
.portal-pipelines__section-h {
.portal-pipelines__expanded-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-pipelines__section-sub {
margin: -0.25rem 0 0.25rem;
.portal-pipelines__expanded-sub {
font-size: 0.75rem;
color: var(--color-text-4);
max-width: 46rem;
}
/* Golden-set roster table */
.portal-pipelines__roster-name,
.portal-pipelines__promoted-name {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
.portal-pipelines__expanded-close {
margin-left: auto;
width: 1.75rem;
height: 1.75rem;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-4);
font-size: 1.125rem;
line-height: 1;
cursor: pointer;
transition:
background var(--motion-fast),
color var(--motion-fast);
}
.portal-pipelines__roster-name strong,
.portal-pipelines__promoted-name strong {
font-size: 0.8125rem;
font-weight: 600;
.portal-pipelines__expanded-close:hover {
background: var(--color-bg-hover);
color: var(--color-text-1);
}
.portal-pipelines__roster-route,
.portal-pipelines__promoted-when {
font-size: 0.6875rem;
color: var(--color-text-4);
/* Detail body */
.portal-pipelines__detail {
display: flex;
flex-direction: column;
gap: 1.125rem;
}
.portal-pipelines__roster-golden {
.portal-pipelines__detail-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.portal-pipelines__detail-heading {
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-5);
font-weight: 600;
}
.portal-pipelines__chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.portal-pipelines__detail-actions {
display: flex;
gap: 0.5rem;
padding-top: 0.875rem;
margin-top: 1rem;
border-top: 1px solid var(--color-border-light);
}
/* Table skeleton */
.portal-pipelines__table-skeleton {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Composer */
/* ──────────────────────────────────────────────────────────────────────── */
.portal-pipelines__composer {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.portal-pipelines__composer-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.portal-pipelines__composer-footer {
display: flex;
justify-content: space-between;
gap: 0.5rem;
width: 100%;
}
.portal-pipelines__source-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 12rem;
overflow-y: auto;
padding: 0.625rem;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
}
/* Operation chain */
.portal-pipelines__chain {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.portal-pipelines__chain-row {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.4375rem 0.625rem;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
}
.portal-pipelines__chain-index {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
border-radius: 50%;
font-size: 0.6875rem;
font-weight: 600;
background: var(--color-blue-light);
color: var(--color-blue);
}
.portal-pipelines__chain-op {
flex: 1;
font-size: 0.8125rem;
color: var(--color-text-1);
}
.portal-pipelines__chain-actions {
display: flex;
gap: 0.25rem;
}
.portal-pipelines__chain-actions button {
width: 1.5rem;
height: 1.5rem;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-3);
font-size: 0.8125rem;
line-height: 1;
cursor: pointer;
transition:
background var(--motion-fast),
color var(--motion-fast);
}
.portal-pipelines__chain-actions button:hover:not(:disabled) {
background: var(--color-bg-hover);
color: var(--color-text-1);
}
.portal-pipelines__chain-actions button:disabled {
opacity: 0.4;
cursor: default;
}
.portal-pipelines__op-palette {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
/* Schedule row */
.portal-pipelines__schedule {
display: flex;
align-items: center;
gap: 0.5rem;
}
.portal-pipelines__roster-rate {
font-size: 0.6875rem;
color: var(--color-text-4);
font-variant-numeric: tabular-nums;
}
.portal-pipelines__roster-num,
.portal-pipelines__roster-version {
font-variant-numeric: tabular-nums;
color: var(--color-text-2);
}
.portal-pipelines__roster-version {
font-family: var(--font-mono);
font-size: 0.75rem;
}
/* Promoted-from-Editor table */
.portal-pipelines__promoted-muted {
color: var(--color-text-3);
font-size: 0.8125rem;
}
.portal-pipelines__promoted-folder {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--color-text-3);
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-sm);
padding: 0.0625rem 0.375rem;
.portal-pipelines__schedule-count {
width: 5rem;
}
@@ -0,0 +1,245 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { HttpError } from "@portal/api/http";
import type { PipelinesOverviewResponse, Policy } from "@portal/api/pipelines";
import type { SourcesResponse } from "@portal/api/sources";
import { Pipelines } from "@portal/views/Pipelines";
// Deterministic i18n: keys returned verbatim, so assertions are stable without
// the async TOML backend.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { changeLanguage: vi.fn() },
}),
}));
const fetchPipelines = vi.fn();
const fetchPipeline = vi.fn();
const savePipeline = vi.fn();
const deletePipeline = vi.fn();
const fetchTriggers = vi.fn();
const triggerPipeline = vi.fn();
const fetchRun = vi.fn();
vi.mock("@portal/api/pipelines", () => ({
fetchPipelines: () => fetchPipelines(),
fetchPipeline: (id: string) => fetchPipeline(id),
savePipeline: (policy: unknown) => savePipeline(policy),
deletePipeline: (id: string) => deletePipeline(id),
fetchTriggers: () => fetchTriggers(),
triggerPipeline: (id: string) => triggerPipeline(id),
fetchRun: (runId: string) => fetchRun(runId),
}));
const fetchSources = vi.fn();
vi.mock("@portal/api/sources", () => ({
fetchSources: () => fetchSources(),
}));
const RESPONSE: PipelinesOverviewResponse = {
kpis: [
{ value: 2, description: "" },
{ value: 2, description: "" },
{ value: 0, description: "" },
],
pipelines: [
{
id: "plc-redaction",
name: "Redaction sweep",
enabled: true,
status: "active",
trigger: "schedule",
sources: [{ id: "src-claims", name: "Claims intake" }],
steps: ["/api/v1/security/auto-redact"],
output: "inline",
owner: "security@acme.com",
},
{
id: "plc-archive",
name: "Archive compressor",
enabled: true,
status: "active",
trigger: "manual",
sources: [],
steps: ["/api/v1/misc/compress-pdf"],
output: "folder",
owner: "data@acme.com",
},
],
};
const RAW_REDACTION: Policy = {
id: "plc-redaction",
name: "Redaction sweep",
enabled: true,
trigger: {
type: "schedule",
options: { schedule: { type: "every", count: 6, unit: "HOURS" } },
},
sourceIds: ["src-claims"],
steps: [{ operation: "/api/v1/security/auto-redact", parameters: {} }],
output: { type: "inline", options: {} },
};
const SOURCES: SourcesResponse = {
kpis: [],
sources: [
{
id: "src-claims",
name: "Claims intake",
type: "folder",
status: "active",
referenceCount: 1,
referencingPolicies: [],
config: [],
docsTotal: null,
},
],
};
function renderView() {
return render(
<MemoryRouter>
<Pipelines />
</MemoryRouter>,
);
}
describe("Pipelines view", () => {
beforeEach(() => {
fetchPipelines.mockReset();
fetchPipeline.mockReset();
savePipeline.mockReset();
deletePipeline.mockReset();
fetchSources.mockReset();
fetchTriggers.mockReset();
triggerPipeline.mockReset();
fetchRun.mockReset();
// The composer loads the trigger registry on open; default to none.
fetchTriggers.mockResolvedValue([]);
});
it("surfaces the inline error message when a delete fails", async () => {
fetchPipelines.mockResolvedValue(RESPONSE);
deletePipeline.mockRejectedValue(
new HttpError(500, "Server Error", {
detail: "Could not delete pipeline",
}),
);
renderView();
fireEvent.click(await screen.findByText("Redaction sweep"));
fireEvent.click(await screen.findByText("pipelines.detail.delete"));
fireEvent.click(await screen.findByText("pipelines.delete.confirm"));
await waitFor(() => {
expect(deletePipeline).toHaveBeenCalledWith("plc-redaction");
});
expect(
await screen.findByText("Could not delete pipeline"),
).toBeInTheDocument();
});
it("pauses a pipeline by re-saving it with enabled flipped off", async () => {
fetchPipelines.mockResolvedValue(RESPONSE);
fetchPipeline.mockResolvedValue(RAW_REDACTION);
savePipeline.mockResolvedValue({});
renderView();
fireEvent.click(await screen.findByText("Redaction sweep"));
fireEvent.click(await screen.findByText("pipelines.detail.pause"));
await waitFor(() => {
expect(savePipeline).toHaveBeenCalledTimes(1);
});
expect(fetchPipeline).toHaveBeenCalledWith("plc-redaction");
expect(savePipeline).toHaveBeenCalledWith(
expect.objectContaining({ id: "plc-redaction", enabled: false }),
);
});
it("runs a pipeline now and reports success inline", async () => {
fetchPipelines.mockResolvedValue(RESPONSE);
triggerPipeline.mockResolvedValue(["run-1"]);
fetchRun.mockResolvedValue({
runId: "run-1",
policyId: "plc-redaction",
status: "COMPLETED",
currentStep: 1,
stepCount: 1,
error: null,
errorCode: null,
createdAt: 0,
});
renderView();
fireEvent.click(await screen.findByText("Redaction sweep"));
fireEvent.click(await screen.findByText("pipelines.detail.run"));
await waitFor(() => {
expect(triggerPipeline).toHaveBeenCalledWith("plc-redaction");
});
expect(
await screen.findByText("pipelines.run.completed"),
).toBeInTheDocument();
});
it("surfaces an execution failure from a manual run", async () => {
fetchPipelines.mockResolvedValue(RESPONSE);
triggerPipeline.mockResolvedValue(["run-1"]);
fetchRun.mockResolvedValue({
runId: "run-1",
policyId: "plc-redaction",
status: "FAILED",
currentStep: 1,
stepCount: 1,
error: "step 1 blew up",
errorCode: null,
createdAt: 0,
});
renderView();
fireEvent.click(await screen.findByText("Redaction sweep"));
fireEvent.click(await screen.findByText("pipelines.detail.run"));
expect(await screen.findByText("pipelines.run.failed")).toBeInTheDocument();
});
it("creates a pipeline with the chosen name and chained operation", async () => {
fetchPipelines.mockResolvedValue(RESPONSE);
fetchSources.mockResolvedValue(SOURCES);
savePipeline.mockResolvedValue({});
renderView();
// Wait for the table so the initial fetch has settled, then open the composer.
await screen.findByText("Redaction sweep");
fireEvent.click(screen.getByText("pipelines.actions.newPipeline"));
fireEvent.change(await screen.findByRole("textbox"), {
target: { value: "Nightly compress" },
});
// Operation palette chip labels are derived from the endpoint path.
fireEvent.click(await screen.findByText("+ Compress"));
fireEvent.click(screen.getByText("pipelines.composer.create"));
await waitFor(() => {
expect(savePipeline).toHaveBeenCalledTimes(1);
});
expect(savePipeline).toHaveBeenCalledWith(
expect.objectContaining({
name: "Nightly compress",
trigger: null,
output: expect.objectContaining({ type: "inline" }),
steps: [
expect.objectContaining({ operation: "/api/v1/misc/compress-pdf" }),
],
}),
);
});
});
+152 -109
View File
@@ -1,162 +1,205 @@
import { useMemo, useState } from "react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Banner,
Button,
Drawer,
EmptyState,
StatusBadge,
Modal,
Skeleton,
} from "@shared/components";
import { useTier } from "@portal/contexts/TierContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import { errorMessage } from "@portal/api/http";
import {
deletePipeline,
fetchPipeline,
fetchPipelines,
type Pipeline,
type PipelinesResponse,
savePipeline,
type PipelinesOverviewResponse,
type PipelineView,
type Policy,
} from "@portal/api/pipelines";
import { DeployedPipelinesTable } from "@portal/components/pipelines/DeployedPipelinesTable";
import { PipelineCard } from "@portal/components/pipelines/PipelineCard";
import { KpiStrip } from "@portal/components/pipelines/KpiStrip";
import { PipelinesTable } from "@portal/components/pipelines/PipelinesTable";
import { PipelineDetailCard } from "@portal/components/pipelines/PipelineDetailCard";
import { PipelineComposer } from "@portal/components/pipelines/PipelineComposer";
import { PipelineDetail } from "@portal/components/pipelines/PipelineDetail";
import { PipelineListSkeleton } from "@portal/components/pipelines/PipelineListSkeleton";
import { PromotedPipelines } from "@portal/components/pipelines/PromotedPipelines";
import "@portal/views/Pipelines.css";
export function Pipelines() {
const { t } = useTranslation();
const { tier } = useTier();
const state = useAsync<PipelinesResponse>(() => fetchPipelines(tier), [tier]);
const { data } = state;
const { isLoading } = useSectionFlags(state);
// Refetch after every mutation by bumping this counter, so the table reflects
// the backend (mirrors the Sources view).
const [version, setVersion] = useState(0);
const state = useAsync<PipelinesOverviewResponse>(
() => fetchPipelines(),
[version],
);
const { data, loading } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
const refetch = useCallback(() => setVersion((v) => v + 1), []);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [composerOpen, setComposerOpen] = useState(false);
const [selected, setSelected] = useState<Pipeline | null>(null);
const [editing, setEditing] = useState<Policy | null>(null);
const [mutating, setMutating] = useState(false);
const [pageError, setPageError] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<PipelineView | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const pipelines = data?.pipelines ?? [];
const evals = data?.evals ?? null;
const promoted = data?.promoted ?? [];
const isEmpty = !isLoading && pipelines.length === 0;
const expanded = pipelines.find((p) => p.id === expandedId) ?? null;
const fleetHealthy = useMemo(
() => pipelines.filter((p) => p.status === "healthy").length,
[pipelines],
);
function openCreate() {
setEditing(null);
setComposerOpen(true);
}
// Editing needs the raw policy (steps, trigger, source ids), which the overview
// rows don't carry, so fetch it before opening the composer prefilled.
async function openEdit(pipeline: PipelineView) {
if (mutating) return;
setPageError(null);
setMutating(true);
try {
setEditing(await fetchPipeline(pipeline.id));
setComposerOpen(true);
} catch (e) {
setPageError(errorMessage(e));
} finally {
setMutating(false);
}
}
// Pause/resume: re-save the policy with enabled flipped (the backend has no
// dedicated endpoint; every mutation routes through POST /policies). Fetch the
// raw record first so the full config round-trips intact.
async function togglePause(pipeline: PipelineView) {
if (mutating) return;
setPageError(null);
setMutating(true);
try {
const raw = await fetchPipeline(pipeline.id);
await savePipeline({ ...raw, enabled: !raw.enabled });
refetch();
} catch (e) {
setPageError(errorMessage(e));
} finally {
setMutating(false);
}
}
function requestDelete(pipeline: PipelineView) {
setDeleteError(null);
setPendingDelete(pipeline);
}
async function confirmDelete() {
if (!pendingDelete || deleting) return;
setDeleting(true);
setDeleteError(null);
try {
await deletePipeline(pendingDelete.id);
setPendingDelete(null);
setExpandedId(null);
refetch();
} catch (e) {
setDeleteError(errorMessage(e));
} finally {
setDeleting(false);
}
}
return (
<div className="portal-pipelines">
<header className="portal-pipelines__header">
<header className="portal-pipelines__head">
<div>
<h1 className="portal-pipelines__title">{t("pipelines.title")}</h1>
<p className="portal-pipelines__sub">{t("pipelines.subtitle")}</p>
</div>
<Button
variant="gradient"
onClick={() => setComposerOpen(true)}
leadingIcon={<span aria-hidden>+</span>}
>
{t("pipelines.newPipeline")}
<Button onClick={openCreate} leadingIcon={<span aria-hidden>+</span>}>
{t("pipelines.actions.newPipeline")}
</Button>
</header>
{!isLoading && pipelines.length > 0 && (
<div className="portal-pipelines__fleet">
<StatusBadge tone="success" size="sm">
{t("pipelines.fleet.healthy", { count: fleetHealthy })}
</StatusBadge>
{fleetHealthy < pipelines.length && (
<StatusBadge tone="warning" size="sm">
{t("pipelines.fleet.degraded", {
count: pipelines.length - fleetHealthy,
})}
</StatusBadge>
)}
<span className="portal-pipelines__fleet-count">
{t("pipelines.fleet.deployed", { count: pipelines.length })}
</span>
{pageError && <Banner tone="danger" description={pageError} />}
<KpiStrip data={data} loading={loading} />
{isLoading && (
<div className="portal-pipelines__table-skeleton" aria-hidden>
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} height="3rem" />
))}
</div>
)}
{tier === "enterprise" && evals && (
<Banner tone="info" title={t("pipelines.evals.title")}>
{t("pipelines.evals.body", {
count: evals.shadowCount,
comparativeCount: evals.comparativeCount,
detail: evals.detail,
})}
</Banner>
)}
{isLoading && <PipelineListSkeleton />}
{isEmpty && (
<EmptyState
title={t("pipelines.empty.title")}
description={t("pipelines.empty.description")}
actions={
<Button variant="gradient" onClick={() => setComposerOpen(true)}>
{t("pipelines.empty.action")}
</Button>
<Button onClick={openCreate}>{t("pipelines.empty.action")}</Button>
}
/>
)}
{pipelines.length > 0 && (
<section className="portal-pipelines__section">
<h2 className="portal-pipelines__section-h">
{t("pipelines.reliability.heading")}
</h2>
<p className="portal-pipelines__section-sub">
{t("pipelines.reliability.description")}
</p>
<DeployedPipelinesTable
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>
);
}
+106
View File
@@ -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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.2 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.2 KiB