diff --git a/.taskfiles/backend.yml b/.taskfiles/backend.yml index 51ae93dc07..2be287bd0f 100644 --- a/.taskfiles/backend.yml +++ b/.taskfiles/backend.yml @@ -26,6 +26,7 @@ tasks: AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}' SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}' + POLICIES_ENABLED: '{{.POLICIES_ENABLED}}' dev:proprietary: desc: "Start backend dev server in proprietary mode" @@ -36,12 +37,13 @@ tasks: AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}' SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}' + POLICIES_ENABLED: '{{.POLICIES_ENABLED | default ""}}' env: SERVER_PORT: '{{.PORT}}' cmds: - - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"' + - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"' platforms: [windows] - - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}./gradlew :stirling-pdf:bootRun' + - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{end}}./gradlew :stirling-pdf:bootRun' platforms: [linux, darwin] dev:bundled: diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index bcc7c07362..1318baf803 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -404,10 +404,22 @@ tasks: test: desc: "Run tests" + cmds: + - task: test:editor + - task: test:portal + + test:editor: + desc: "Run editor tests" deps: [prepare] cmds: - npx vitest run --root editor + test:portal: + desc: "Run portal tests" + deps: [prepare] + cmds: + - npx vitest run --root portal + test:watch: desc: "Run tests in watch mode" deps: [prepare] diff --git a/Taskfile.yml b/Taskfile.yml index 2c776f7ad1..f04967740f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -90,6 +90,7 @@ tasks: vars: PORT: '{{.BACKEND_PORT}}' SECURITY_ENABLELOGIN: "true" + POLICIES_ENABLED: "true" - task: frontend:dev:portal vars: PORT: '{{.PORTAL_PORT}}' @@ -109,6 +110,7 @@ tasks: vars: PORT: '{{.BACKEND_PORT}}' SECURITY_ENABLELOGIN: "true" + POLICIES_ENABLED: "true" - task: frontend:dev:portal vars: PORT: '{{.PORTAL_PORT}}' @@ -135,6 +137,7 @@ tasks: vars: PORT: '{{.BACKEND_PORT}}' SECURITY_ENABLELOGIN: "true" + POLICIES_ENABLED: "true" - task: frontend:dev:proprietary vars: PORT: '{{.EDITOR_PORT}}' @@ -210,6 +213,7 @@ tasks: vars: PORT: '{{.BACKEND_PORT}}' SECURITY_ENABLELOGIN: "true" + POLICIES_ENABLED: "true" - task: frontend:preview:portal:proxy vars: PORT: '{{.PROXY_PORT}}' diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 8f7e878aef..7da9d76c6c 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -206,6 +206,11 @@ public class ApplicationProperties { @Data public static class Policies { + /** + * Master switch for the policy + sources subsystem (the PAYG-metered automation surface). + */ + private boolean enabled = false; + /** * Absolute directories that policy folder input sources and output sinks may read from or * write to. Empty (the default) disables folder access entirely, so a policy can never be diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java index 899302f5b0..d61a006cd9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java @@ -4,14 +4,16 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Optional; -import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.core.env.Environment; import org.springframework.stereotype.Component; import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.source.SourceStore; /** * Authority on which filesystem locations a policy may read/write. Checked at save time and again @@ -28,7 +30,7 @@ import stirling.software.proprietary.policy.model.Policy; * defended: an operator who roots an allowlist on a symlink to a sensitive location is trusted. */ @Component -@Profile("saas") +@ConditionalOnBooleanProperty(name = "policies.enabled") public class FolderAccessGuard { public static final String FOLDER_TYPE = "folder"; @@ -36,12 +38,17 @@ public class FolderAccessGuard { private final boolean saasActive; private final List allowedRoots; private final List protectedRoots; + private final SourceStore sourceStore; - public FolderAccessGuard(ApplicationProperties applicationProperties, Environment environment) { + public FolderAccessGuard( + ApplicationProperties applicationProperties, + Environment environment, + SourceStore sourceStore) { this.saasActive = Arrays.asList(environment.getActiveProfiles()).contains("saas"); this.allowedRoots = normalizeAll(applicationProperties.getPolicies().getAllowedFolderRoots()); this.protectedRoots = List.of(normalize(Path.of(InstallationPathConfig.getConfigPath()))); + this.sourceStore = sourceStore; } /** Returns the normalised absolute path; throws if not permitted. */ @@ -72,7 +79,10 @@ public class FolderAccessGuard { /** Whether this policy touches a folder source/sink, and so is subject to these rules. */ public boolean usesFolderAccess(Policy policy) { boolean readsFolder = - policy.sources().stream().anyMatch(spec -> FOLDER_TYPE.equals(spec.type())); + policy.sourceIds().stream() + .map(sourceStore::get) + .flatMap(Optional::stream) + .anyMatch(source -> FOLDER_TYPE.equals(source.type())); boolean writesFolder = policy.output() != null && FOLDER_TYPE.equals(policy.output().type()); return readsFolder || writesFolder; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java index c7b10b941f..061c788052 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java @@ -3,7 +3,7 @@ package stirling.software.proprietary.policy.config; import java.util.List; import java.util.Objects; -import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.stereotype.Component; import lombok.RequiredArgsConstructor; @@ -11,6 +11,7 @@ import lombok.RequiredArgsConstructor; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.UserServiceInterface; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.store.PolicyStore; /** * Policies are scoped to a team: a user may view, run, edit, and delete only the policies belonging @@ -22,7 +23,7 @@ import stirling.software.proprietary.policy.model.Policy; */ @Component @RequiredArgsConstructor -@Profile("saas") +@ConditionalOnBooleanProperty(name = "policies.enabled") public class PolicyAccessGuard { private final UserServiceInterface userService; @@ -47,13 +48,16 @@ public class PolicyAccessGuard { return Objects.equals(policy.teamId(), policyManagementAuthority.currentUserTeamId()); } - /** The subset of {@code policies} scoped to the current user's team. */ - public List visible(List policies) { + /** + * The policies visible to the caller: their whole team's, loaded scoped rather than fetched + * globally and filtered, so on SaaS it never pulls another team's policies into memory. Login + * disabled (single-user) returns everything. + */ + public List visibleFrom(PolicyStore store) { if (!enforced()) { - return policies; + return store.all(); } - Long teamId = policyManagementAuthority.currentUserTeamId(); - return policies.stream().filter(policy -> Objects.equals(policy.teamId(), teamId)).toList(); + return store.findByTeam(policyManagementAuthority.currentUserTeamId()); } private boolean enforced() { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index f2b751bca7..8a06582b58 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -6,7 +6,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; @@ -53,7 +53,10 @@ 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.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.PolicyTriggerManager; /** * Policy CRUD plus pipeline runs (stored or ad-hoc). Runs are async: returns a run id, poll {@code @@ -65,15 +68,18 @@ import stirling.software.proprietary.policy.store.PolicyStore; @Hidden @RequiredArgsConstructor @Tag(name = "Policies", description = "Run tool pipelines on the backend") -@Profile("saas") +@ConditionalOnBooleanProperty(name = "policies.enabled") public class PolicyController { private final PolicyRunner policyRunner; private final PolicyRunRegistry runRegistry; private final PolicyStore policyStore; + private final SourceStore sourceStore; + private final SourceAccessGuard sourceAccessGuard; private final PolicyValidator policyValidator; private final PolicyAccessGuard policyAccessGuard; private final PolicyManagementAuthority policyManagementAuthority; + private final PolicyTriggerManager policyTriggerManager; private final ApplicationProperties applicationProperties; private final TempFileManager tempFileManager; private final JobOwnershipService jobOwnershipService; @@ -187,12 +193,33 @@ public class PolicyController { public ResponseEntity savePolicy(@RequestBody Policy policy) { requirePolicyEditingAllowed(); Policy owned = resolveOwnership(policy); + requireAccessibleSources(owned); try { policyValidator.validate(owned); } catch (IllegalArgumentException e) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); } - return ResponseEntity.ok(policyStore.save(owned)); + Policy saved = policyStore.save(owned); + // Re-sync trigger registrations now so a new/changed folder-watch policy starts being + // watched immediately instead of after the next reconcile sweep. + policyTriggerManager.notifyPoliciesChanged(); + return ResponseEntity.ok(saved); + } + + /** + * Every {@code sourceId} a policy references must resolve to a source in the caller's team, so + * a client can neither reference a non-existent source nor reach across teams to use another + * team's connection. A bad reference is a client error. + */ + private void requireAccessibleSources(Policy policy) { + for (String sourceId : policy.sourceIds()) { + boolean accessible = + sourceStore.get(sourceId).filter(sourceAccessGuard::canAccess).isPresent(); + if (!accessible) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Unknown or inaccessible source: " + sourceId); + } + } } /** @@ -225,7 +252,7 @@ public class PolicyController { owner, policy.enabled(), policy.trigger(), - policy.sources(), + policy.sourceIds(), policy.steps(), policy.output(), teamId); @@ -257,7 +284,7 @@ public class PolicyController { summary = "List policies", description = "Lists the policies belonging to the caller's team.") public List listPolicies() { - return policyAccessGuard.visible(policyStore.all()); + return policyAccessGuard.visibleFrom(policyStore); } @GetMapping("/{policyId}") @@ -278,6 +305,9 @@ public class PolicyController { boolean accessible = policyStore.get(policyId).filter(policyAccessGuard::canAccess).isPresent(); if (accessible && policyStore.delete(policyId)) { + // Cancel any now-orphaned folder watch promptly rather than leaving the WatchKey open + // until the next reconcile sweep. + policyTriggerManager.notifyPoliciesChanged(); return ResponseEntity.noContent().build(); } return ResponseEntity.notFound().build(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java index 28951d52a2..ff5d73efb3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java @@ -9,7 +9,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import org.slf4j.MDC; -import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.core.io.Resource; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; @@ -54,7 +54,7 @@ import stirling.software.proprietary.service.DownstreamEntitlementError; @Slf4j @Service @RequiredArgsConstructor -@Profile("saas") +@ConditionalOnBooleanProperty(name = "policies.enabled") public class PolicyEngine { // Admission weight for one run. Weighted heavy: a run chains many tools and holds intermediate diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunRegistry.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunRegistry.java index 1c88191396..f0a0a9d6b0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunRegistry.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunRegistry.java @@ -9,7 +9,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; -import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.stereotype.Service; import jakarta.annotation.PreDestroy; @@ -29,7 +29,7 @@ import stirling.software.proprietary.policy.model.PolicyRun; */ @Slf4j @Service -@Profile("saas") +@ConditionalOnBooleanProperty(name = "policies.enabled") public class PolicyRunRegistry { private final Map runs = new ConcurrentHashMap<>(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java index 9aa7d284c3..27f43c74da 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java @@ -4,7 +4,7 @@ import java.io.IOException; import java.util.List; import java.util.function.Consumer; -import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; @@ -19,33 +19,51 @@ 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.progress.PolicyProgressListener; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; /** - * Turns a policy's configured {@link InputSpec sources} into runs. Triggers decide when - * and call {@link #run(Policy)}; the controller uses the supplied-input and ad-hoc entry points. + * Turns a policy's referenced sources into runs: each {@code sourceId} is resolved live to its + * persisted {@link Source}, then to an {@link InputSpec}. Triggers decide when and call + * {@link #run(Policy)}; the controller uses the supplied-input and ad-hoc entry points. */ @Slf4j @Service @RequiredArgsConstructor -@Profile("saas") +@ConditionalOnBooleanProperty(name = "policies.enabled") public class PolicyRunner { private final PolicyEngine policyEngine; private final List inputSources; + private final SourceStore sourceStore; /** - * Trigger entry point. Pulls every configured source; each yielded unit becomes its own run so + * 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). + * pipeline). Missing or disabled sources are skipped so one broken reference does not stop the + * rest. */ public void run(Policy policy) { - List sources = policy.sources(); - if (sources.isEmpty()) { + List sourceIds = policy.sourceIds(); + if (sourceIds.isEmpty()) { startRun(policy, PolicyInputs.of(List.of()), unused -> {}); return; } - for (InputSpec spec : sources) { - pullAndRun(policy, spec); + for (String sourceId : sourceIds) { + Source source = sourceStore.get(sourceId).orElse(null); + if (source == null) { + log.warn("Policy {} references missing source {}; skipping", policy.id(), sourceId); + continue; + } + if (!source.enabled()) { + log.debug( + "Source {} ({}) is disabled; skipping for policy {}", + sourceId, + source.name(), + policy.id()); + continue; + } + pullAndRun(policy, source.toInputSpec()); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java index 6801ff38ce..c08d2dd857 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java @@ -2,7 +2,7 @@ package stirling.software.proprietary.policy.engine; import java.util.List; -import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; @@ -13,31 +13,44 @@ import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.TriggerConfig; import stirling.software.proprietary.policy.output.PolicyOutputSink; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; import stirling.software.proprietary.policy.trigger.PolicyTrigger; /** * Validates a policy at save time by delegating each facet (trigger, sources, output) to the bean * that handles its type, so a misconfiguration fails fast rather than at run time. A null trigger - * is a manual-only policy and skips trigger validation. + * is a manual-only policy and skips trigger validation. Each referenced {@code sourceId} must + * resolve to a persisted {@link Source} whose config its {@link InputSource} bean accepts. */ @Service @RequiredArgsConstructor -@Profile("saas") +@ConditionalOnBooleanProperty(name = "policies.enabled") public class PolicyValidator { private final List triggers; private final List inputSources; private final List outputSinks; + private final SourceStore sourceStore; /** - * @throws IllegalArgumentException if any facet's type is unknown or its config is invalid + * @throws IllegalArgumentException if any facet's type is unknown, a referenced source does not + * exist, or any config is invalid */ public void validate(Policy policy) { if (policy.trigger() != null) { triggerFor(policy.trigger()).validate(policy); } - for (InputSpec source : policy.sources()) { - inputSourceFor(source).validate(source); + for (String sourceId : policy.sourceIds()) { + Source source = + sourceStore + .get(sourceId) + .orElseThrow( + () -> + new IllegalArgumentException( + "unknown source: " + sourceId)); + InputSpec spec = source.toInputSpec(); + inputSourceFor(spec).validate(spec); } outputSinkFor(policy.output()).validate(policy.output()); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java index 0990e83c18..c00f5b4a33 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java @@ -9,7 +9,7 @@ import java.util.List; import java.util.Map; import java.util.stream.Stream; -import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; @@ -34,7 +34,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs; @Slf4j @Service @RequiredArgsConstructor -@Profile("saas") +@ConditionalOnBooleanProperty(name = "policies.enabled") public class FolderInputSource implements InputSource { private static final String TYPE = FolderAccessGuard.FOLDER_TYPE; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java index 6d5366eb47..51dcc4ac0c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java @@ -6,8 +6,9 @@ import java.util.List; * A stored automation: ordered tool steps, input sources, and an output destination. * *

Always runnable on demand. An optional {@link TriggerConfig} fires it automatically; a {@code - * null} trigger means manual-only. Trigger decides when, {@link InputSpec sources} decide where - * files come from; a run pulls from every source. + * null} trigger means manual-only. Trigger decides when; {@code sourceIds} reference the persisted + * {@code Source} connections (resolved live at run time) that decide where files come from; a run + * pulls from every referenced source. */ public record Policy( String id, @@ -15,13 +16,13 @@ public record Policy( String owner, boolean enabled, TriggerConfig trigger, - List sources, + List sourceIds, List steps, OutputSpec output, Long teamId) { public Policy { - sources = sources == null ? List.of() : List.copyOf(sources); + sourceIds = sourceIds == null ? List.of() : List.copyOf(sourceIds); steps = steps == null ? List.of() : steps; output = output == null ? OutputSpec.inline() : output; } @@ -36,10 +37,10 @@ public record Policy( String owner, boolean enabled, TriggerConfig trigger, - List sources, + List sourceIds, List steps, OutputSpec output) { - this(id, name, owner, enabled, trigger, sources, steps, output, null); + this(id, name, owner, enabled, trigger, sourceIds, steps, output, null); } /** A policy with no configured sources (a generator, or files supplied directly to a run). */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java index 94a9d34a35..8821a2940b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java @@ -9,7 +9,7 @@ import java.util.List; import java.util.UUID; import org.apache.commons.io.FilenameUtils; -import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.core.io.Resource; import org.springframework.http.MediaType; import org.springframework.http.MediaTypeFactory; @@ -31,7 +31,7 @@ import stirling.software.proprietary.policy.model.OutputSpec; @Slf4j @Service @RequiredArgsConstructor -@Profile("saas") +@ConditionalOnBooleanProperty(name = "policies.enabled") public class FolderOutputSink implements PolicyOutputSink { static final String TYPE = FolderAccessGuard.FOLDER_TYPE; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java index 799072aeaa..0fcd7323ff 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java @@ -5,7 +5,7 @@ import java.io.InputStream; import java.util.ArrayList; import java.util.List; -import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.core.io.Resource; import org.springframework.http.MediaType; import org.springframework.http.MediaTypeFactory; @@ -23,7 +23,7 @@ import stirling.software.proprietary.policy.model.OutputSpec; */ @Service @RequiredArgsConstructor -@Profile("saas") +@ConditionalOnBooleanProperty(name = "policies.enabled") public class InlineOutputSink implements PolicyOutputSink { private static final String TYPE = "inline"; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/InProcessSourceStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/InProcessSourceStore.java new file mode 100644 index 0000000000..0d3897c529 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/InProcessSourceStore.java @@ -0,0 +1,58 @@ +package stirling.software.proprietary.policy.source; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * In-memory {@link SourceStore} for tests and any future no-database mode. {@link JpaSourceStore} + * is the runtime bean. + */ +public class InProcessSourceStore implements SourceStore { + + private final Map sources = new ConcurrentHashMap<>(); + + @Override + public Source save(Source source) { + String id = + source.id() == null || source.id().isBlank() + ? UUID.randomUUID().toString() + : source.id(); + Source stored = + new Source( + id, + source.name(), + source.type(), + source.options(), + source.enabled(), + source.owner(), + source.teamId()); + sources.put(id, stored); + return stored; + } + + @Override + public Optional get(String id) { + return Optional.ofNullable(sources.get(id)); + } + + @Override + public List all() { + return List.copyOf(sources.values()); + } + + @Override + public List findByTeam(Long teamId) { + return sources.values().stream() + .filter(source -> Objects.equals(source.teamId(), teamId)) + .toList(); + } + + @Override + public boolean delete(String id) { + return sources.remove(id) != null; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceStore.java new file mode 100644 index 0000000000..18bbf5cbdc --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceStore.java @@ -0,0 +1,81 @@ +package stirling.software.proprietary.policy.source; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +import tools.jackson.databind.ObjectMapper; + +/** + * Durable {@link SourceStore} backed by JPA; the runtime store. Sources are persisted as JSON via + * {@link SourceEntity}, with scalar columns kept in sync for querying. + */ +@Service +@RequiredArgsConstructor +@ConditionalOnBooleanProperty(name = "policies.enabled") +public class JpaSourceStore implements SourceStore { + + private final SourceRepository repository; + private final ObjectMapper objectMapper; + + @Override + public Source save(Source source) { + String id = + source.id() == null || source.id().isBlank() + ? UUID.randomUUID().toString() + : source.id(); + Source stored = + new Source( + id, + source.name(), + source.type(), + source.options(), + source.enabled(), + source.owner(), + source.teamId()); + + SourceEntity entity = new SourceEntity(); + entity.setId(id); + entity.setName(stored.name()); + entity.setType(stored.type()); + entity.setOwner(stored.owner()); + entity.setTeamId(stored.teamId()); + entity.setEnabled(stored.enabled()); + entity.setSourceJson(objectMapper.writeValueAsString(stored)); + repository.save(entity); + return stored; + } + + @Override + public Optional get(String id) { + return repository.findById(id).map(this::toSource); + } + + @Override + public List all() { + return repository.findAll().stream().map(this::toSource).toList(); + } + + @Override + public List findByTeam(Long teamId) { + return repository.findByTeam(teamId).stream().map(this::toSource).toList(); + } + + @Override + public boolean delete(String id) { + if (!repository.existsById(id)) { + return false; + } + repository.deleteById(id); + return true; + } + + private Source toSource(SourceEntity entity) { + return objectMapper.readValue(entity.getSourceJson(), Source.class); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/Source.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/Source.java new file mode 100644 index 0000000000..ca80c1b46f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/Source.java @@ -0,0 +1,34 @@ +package stirling.software.proprietary.policy.source; + +import java.util.Map; + +import stirling.software.proprietary.policy.model.InputSpec; + +/** + * A persisted, reusable input connection: the instantiation of a source definition. Policies + * reference sources by {@code id} rather than embedding their config, so one connection is + * configured once and can feed many policies. + * + *

{@code type} keys an {@link stirling.software.proprietary.policy.input.InputSource} bean, + * matching {@link InputSpec#type()}; {@code options} is that source's config. {@code owner} and + * {@code teamId} scope the source to a team, mirroring {@link + * stirling.software.proprietary.policy.model.Policy}. + */ +public record Source( + String id, + String name, + String type, + Map options, + boolean enabled, + String owner, + Long teamId) { + + public Source { + options = options == null ? Map.of() : options; + } + + /** The runtime form the policy engine resolves and runs against. */ + public InputSpec toInputSpec() { + return new InputSpec(type, options); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceAccessGuard.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceAccessGuard.java new file mode 100644 index 0000000000..1db5663fd2 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceAccessGuard.java @@ -0,0 +1,63 @@ +package stirling.software.proprietary.policy.source; + +import java.util.List; +import java.util.Objects; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.UserServiceInterface; +import stirling.software.proprietary.policy.config.PolicyManagementAuthority; + +/** + * Sources are scoped to a team exactly like policies: a user may view, edit, and delete only the + * sources belonging to their own team (the team a source is stamped with at creation). Enforced + * only when login is enabled; single-user deployments (login disabled) pass every check. Mirrors + * {@link stirling.software.proprietary.policy.config.PolicyAccessGuard}. + */ +@Component +@RequiredArgsConstructor +@ConditionalOnBooleanProperty(name = "policies.enabled") +public class SourceAccessGuard { + + private final UserServiceInterface userService; + private final ApplicationProperties applicationProperties; + private final PolicyManagementAuthority policyManagementAuthority; + + /** Owner for a new source: the current user, or {@code null} when login is disabled. */ + public String ownerForNewSource() { + return enforced() ? userService.getCurrentUsername() : null; + } + + /** Team a new source is stamped with: the creator's team. {@code null} when login disabled. */ + public Long teamForNewSource() { + return enforced() ? policyManagementAuthority.currentUserTeamId() : null; + } + + /** Whether the source belongs to the current user's team (so they may view/edit it). */ + public boolean canAccess(Source source) { + if (!enforced()) { + return true; + } + return Objects.equals(source.teamId(), policyManagementAuthority.currentUserTeamId()); + } + + /** + * The sources visible to the caller: their whole team's, loaded scoped rather than fetched + * globally and filtered, so on SaaS it never pulls another team's sources into memory. Login + * disabled (single-user) returns everything. + */ + public List visibleFrom(SourceStore store) { + if (!enforced()) { + return store.all(); + } + return store.findByTeam(policyManagementAuthority.currentUserTeamId()); + } + + private boolean enforced() { + return applicationProperties.getSecurity().isEnableLogin(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java new file mode 100644 index 0000000000..5755eb332f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java @@ -0,0 +1,191 @@ +package stirling.software.proprietary.policy.source; + +import java.util.List; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.policy.config.PolicyAccessGuard; +import stirling.software.proprietary.policy.config.PolicyManagementAuthority; +import stirling.software.proprietary.policy.input.InputSource; +import stirling.software.proprietary.policy.model.InputSpec; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.store.PolicyStore; +import stirling.software.proprietary.policy.trigger.PolicyTriggerManager; + +/** + * CRUD for persisted, reusable input connections plus the Sources overview for the admin portal. A + * source is configured once here and referenced by id from any number of policies; the overview + * reports how many reference each one. Editing follows the same team-leader rule as policies, and + * everything is scoped to the caller's team. + */ +@RestController +@RequestMapping("/api/v1/sources") +@Hidden +@RequiredArgsConstructor +@Tag(name = "Sources", description = "Reusable policy input connections") +@ConditionalOnBooleanProperty(name = "policies.enabled") +public class SourceController { + + private final SourceStore sourceStore; + private final SourceAccessGuard sourceAccessGuard; + private final SourceOverviewService overviewService; + private final PolicyStore policyStore; + private final PolicyAccessGuard policyAccessGuard; + private final PolicyManagementAuthority policyManagementAuthority; + private final PolicyTriggerManager policyTriggerManager; + private final ApplicationProperties applicationProperties; + private final List inputSources; + + @GetMapping + @Operation( + summary = "Sources overview", + description = + "Returns the KPI strip plus one row per source the caller's team owns, each with" + + " how many policies reference it and which.") + public SourcesResponse list() { + return overviewService.overview(); + } + + @GetMapping("/{sourceId}") + @Operation(summary = "Get a source by id") + public ResponseEntity get(@PathVariable String sourceId) { + return sourceStore + .get(sourceId) + .filter(sourceAccessGuard::canAccess) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "Create or update a source", + description = + "Stores an input connection (type + config). A blank id is assigned; owner and" + + " team are stamped server-side. The config is validated against the" + + " matching source type.") + public ResponseEntity save(@RequestBody Source source) { + requireSourceEditingAllowed(); + Source owned = resolveOwnership(source); + try { + validateConfig(owned); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); + } + Source saved = sourceStore.save(owned); + // An edited folder source can change which directory needs watching, so re-sync trigger + // registrations now instead of waiting for the next reconcile. + policyTriggerManager.notifyPoliciesChanged(); + return ResponseEntity.ok(saved); + } + + @DeleteMapping("/{sourceId}") + @Operation( + summary = "Delete a source", + description = + "Removes a source that no policy references. A source still in use returns 409" + + " so the connection can't be pulled out from under a live policy.") + public ResponseEntity delete(@PathVariable String sourceId) { + requireSourceEditingAllowed(); + Source source = sourceStore.get(sourceId).filter(sourceAccessGuard::canAccess).orElse(null); + if (source == null) { + return ResponseEntity.notFound().build(); + } + List referencing = referencingPolicyNames(sourceId); + if (!referencing.isEmpty()) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, + "Source is referenced by " + + referencing.size() + + " policy(ies): " + + String.join(", ", referencing)); + } + sourceStore.delete(sourceId); + return ResponseEntity.noContent().build(); + } + + /** + * Stamp owner + team server-side. Create stamps the current user and their team; update + * preserves the existing owner and team after verifying the source belongs to the caller's + * team, so the client can neither forge ownership on create nor reach across teams on update (a + * source in another team reads as not-found). + */ + private Source resolveOwnership(Source incoming) { + String id = incoming.id(); + if (id != null && !id.isBlank()) { + Source existing = sourceStore.get(id).orElse(null); + if (existing != null) { + if (!sourceAccessGuard.canAccess(existing)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No source: " + id); + } + return withOwnerAndTeam(incoming, existing.owner(), existing.teamId()); + } + } + return withOwnerAndTeam( + incoming, + sourceAccessGuard.ownerForNewSource(), + sourceAccessGuard.teamForNewSource()); + } + + private static Source withOwnerAndTeam(Source source, String owner, Long teamId) { + return new Source( + source.id(), + source.name(), + source.type(), + source.options(), + source.enabled(), + owner, + teamId); + } + + /** Validate the config against the bean that handles the source's type, as the engine will. */ + private void validateConfig(Source source) { + InputSpec spec = source.toInputSpec(); + inputSources.stream() + .filter(inputSource -> inputSource.supports(spec)) + .findFirst() + .orElseThrow( + () -> new IllegalArgumentException("unknown source type: " + source.type())) + .validate(spec); + } + + /** + * Editing sources requires the editor role for the caller's team (a team leader on SaaS), the + * same rule as policies. Single-user deployments (login disabled) trust the local operator. + */ + private void requireSourceEditingAllowed() { + if (!applicationProperties.getSecurity().isEnableLogin()) { + return; + } + if (!policyManagementAuthority.canEditPolicies()) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, + "Sources may only be created or modified by a team leader"); + } + } + + /** Names of the caller's visible policies that reference the given source. */ + private List referencingPolicyNames(String sourceId) { + return policyAccessGuard.visibleFrom(policyStore).stream() + .filter(policy -> policy.sourceIds().contains(sourceId)) + .map(Policy::name) + .toList(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceEntity.java new file mode 100644 index 0000000000..412f862f89 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceEntity.java @@ -0,0 +1,50 @@ +package stirling.software.proprietary.policy.source; + +import java.io.Serializable; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * JPA row for a {@link Source}. The whole source lives as JSON in {@code sourceJson} (authoritative + * on read); the scalar columns are denormalized copies for querying. {@code owner} and {@code + * teamId} are plain values, not foreign keys, to stay decoupled from the security entities - + * matching {@link stirling.software.proprietary.policy.store.PolicyEntity}. + */ +@Entity +@Table(name = "policy_sources") +@NoArgsConstructor +@Getter +@Setter +public class SourceEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @Column(name = "id") + private String id; + + @Column(name = "name") + private String name; + + @Column(name = "type") + private String type; + + @Column(name = "owner") + private String owner; + + @Column(name = "team_id") + private Long teamId; + + @Column(name = "enabled") + private boolean enabled; + + @Column(name = "source_json", columnDefinition = "text") + private String sourceJson; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceKpi.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceKpi.java new file mode 100644 index 0000000000..20f97757a2 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceKpi.java @@ -0,0 +1,4 @@ +package stirling.software.proprietary.policy.source; + +/** One headline figure in the Sources overview strip. */ +public record SourceKpi(long value, String description) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java new file mode 100644 index 0000000000..b9bc7c1cb2 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java @@ -0,0 +1,118 @@ +package stirling.software.proprietary.policy.source; + +import java.util.ArrayList; +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.Policy; +import stirling.software.proprietary.policy.store.PolicyStore; + +/** + * Builds the Sources overview: every persisted source the caller's team owns, shown exactly once, + * each annotated with the policies that reference it. Reference counts are derived by scanning the + * team's policies in memory rather than persisted on the source - fine at admin-dashboard scale and + * always consistent with the live policy set. + */ +@Service +@RequiredArgsConstructor +@ConditionalOnBooleanProperty(name = "policies.enabled") +public class SourceOverviewService { + + private final SourceStore sourceStore; + private final PolicyStore policyStore; + private final SourceAccessGuard sourceAccessGuard; + private final PolicyAccessGuard policyAccessGuard; + + public SourcesResponse overview() { + List sources = sourceAccessGuard.visibleFrom(sourceStore); + List policies = policyAccessGuard.visibleFrom(policyStore); + + Map> referencesBySource = referencesBySource(policies); + + List views = + sources.stream() + .map( + source -> + toView( + source, + referencesBySource.getOrDefault( + source.id(), List.of()))) + .sorted( + Comparator.comparingInt(SourceView::referenceCount) + .reversed() + .thenComparing(SourceView::name)) + .toList(); + + return new SourcesResponse(buildKpis(views), views); + } + + /** Policies referencing each source id, across the caller's visible policies. */ + private static Map> referencesBySource(List policies) { + Map> bySource = new HashMap<>(); + for (Policy policy : policies) { + for (String sourceId : policy.sourceIds()) { + bySource.computeIfAbsent(sourceId, key -> new ArrayList<>()).add(policy); + } + } + return bySource; + } + + private static SourceView toView(Source source, List referencingPolicies) { + List refs = + referencingPolicies.stream() + .map(policy -> new SourceView.PolicyRef(policy.id(), policy.name())) + .toList(); + return new SourceView( + source.id(), + source.name(), + source.type(), + deriveStatus(source, refs.size()), + refs.size(), + refs, + configRows(source), + null); + } + + /** A disabled (paused) source reads as "disabled"; an unreferenced one reads as "unused". */ + private static String deriveStatus(Source source, int referenceCount) { + if (!source.enabled()) { + return "disabled"; + } + return referenceCount == 0 ? "unused" : "active"; + } + + /** Generic key/value view of the source's config - works for any source type. */ + private static List configRows(Source source) { + return source.options().entrySet().stream() + .map( + entry -> + new SourceView.DetailRow( + humanize(entry.getKey()), String.valueOf(entry.getValue()))) + .toList(); + } + + private static String humanize(String key) { + if (key == null || key.isBlank()) { + return key; + } + return Character.toUpperCase(key.charAt(0)) + key.substring(1); + } + + private static List buildKpis(List sources) { + long total = sources.size(); + long inUse = sources.stream().filter(source -> source.referenceCount() > 0).count(); + long orphaned = total - inUse; + return List.of( + new SourceKpi(total, "connections"), + new SourceKpi(inUse, "referenced by a policy"), + new SourceKpi(orphaned, "unused")); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceRepository.java new file mode 100644 index 0000000000..b8968fea1a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceRepository.java @@ -0,0 +1,22 @@ +package stirling.software.proprietary.policy.source; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface SourceRepository extends JpaRepository { + + /** + * Sources belonging to a team, loaded without scanning every team's rows. A {@code null} teamId + * matches the rows with no team (login-disabled / pre-team data), mirroring the in-memory team + * filter rather than the empty result a plain {@code = null} would give. + */ + @Query( + "select s from SourceEntity s where (:teamId is null and s.teamId is null) or" + + " s.teamId = :teamId") + List findByTeam(@Param("teamId") Long teamId); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceStore.java new file mode 100644 index 0000000000..64d6fe5a6c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceStore.java @@ -0,0 +1,21 @@ +package stirling.software.proprietary.policy.source; + +import java.util.List; +import java.util.Optional; + +/** Stores {@link Source} definitions (persisted, reusable input connections). */ +public interface SourceStore { + + /** Create or update; a blank/absent id is assigned. Returns the stored source. */ + Source save(Source source); + + Optional get(String id); + + List all(); + + /** Sources owned by the given team, loaded scoped rather than fetched globally. */ + List findByTeam(Long teamId); + + /** Returns whether the source existed. */ + boolean delete(String id); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceView.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceView.java new file mode 100644 index 0000000000..bb1fdbf9d0 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceView.java @@ -0,0 +1,25 @@ +package stirling.software.proprietary.policy.source; + +import java.util.List; + +/** + * One row in the Sources overview: a persisted input connection shown exactly once, with how many + * policies reference it (and which). {@code docsTotal} is {@code null} - per-source document volume + * is not tracked yet; the field is reserved so a later doc-accounting pass is additive. + */ +public record SourceView( + String id, + String name, + String type, + String status, + int referenceCount, + List referencingPolicies, + List config, + Long docsTotal) { + + /** A policy that references this source. */ + public record PolicyRef(String id, String name) {} + + /** A key/value line summarising the source's config for display. */ + public record DetailRow(String label, String value) {} +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourcesResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourcesResponse.java new file mode 100644 index 0000000000..5f50877e9b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourcesResponse.java @@ -0,0 +1,6 @@ +package stirling.software.proprietary.policy.source; + +import java.util.List; + +/** The Sources overview payload: a KPI strip plus one row per source. */ +public record SourcesResponse(List kpis, List sources) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java index d4a1259d3d..b6d882bd6d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java @@ -2,6 +2,7 @@ package stirling.software.proprietary.policy.store; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -29,7 +30,7 @@ public class InProcessPolicyStore implements PolicyStore { policy.owner(), policy.enabled(), policy.trigger(), - policy.sources(), + policy.sourceIds(), policy.steps(), policy.output(), policy.teamId()); @@ -47,6 +48,13 @@ public class InProcessPolicyStore implements PolicyStore { return List.copyOf(policies.values()); } + @Override + public List findByTeam(Long teamId) { + return policies.values().stream() + .filter(policy -> Objects.equals(policy.teamId(), teamId)) + .toList(); + } + @Override public List findByTriggerType(String triggerType) { return policies.values().stream() diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java index 085a97b5a7..4de6ee2f3a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java @@ -4,7 +4,7 @@ import java.util.List; import java.util.Optional; import java.util.UUID; -import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; @@ -19,7 +19,7 @@ import tools.jackson.databind.ObjectMapper; */ @Service @RequiredArgsConstructor -@Profile("saas") +@ConditionalOnBooleanProperty(name = "policies.enabled") public class JpaPolicyStore implements PolicyStore { private final PolicyRepository repository; @@ -38,7 +38,7 @@ public class JpaPolicyStore implements PolicyStore { policy.owner(), policy.enabled(), policy.trigger(), - policy.sources(), + policy.sourceIds(), policy.steps(), policy.output(), policy.teamId()); @@ -49,6 +49,7 @@ public class JpaPolicyStore implements PolicyStore { entity.setOwner(stored.owner()); entity.setEnabled(stored.enabled()); entity.setTriggerType(stored.trigger() == null ? null : stored.trigger().type()); + entity.setTeamId(stored.teamId()); entity.setPolicyJson(objectMapper.writeValueAsString(stored)); repository.save(entity); return stored; @@ -64,6 +65,11 @@ public class JpaPolicyStore implements PolicyStore { return repository.findAll().stream().map(this::toPolicy).toList(); } + @Override + public List findByTeam(Long teamId) { + return repository.findByTeam(teamId).stream().map(this::toPolicy).toList(); + } + @Override public List findByTriggerType(String triggerType) { return repository.findByTriggerTypeAndEnabledTrue(triggerType).stream() diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java index 944b494611..4b99a90e6c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java @@ -15,8 +15,9 @@ import lombok.Setter; * JPA row for a {@link stirling.software.proprietary.policy.model.Policy}. The whole policy lives * as JSON in {@code policyJson} (authoritative on read); the scalar columns are denormalized copies * for querying, notably {@code triggerType} + {@code enabled} so background triggers can fetch - * their policies. {@code owner} is a plain string, not a foreign key, to stay decoupled from the - * security entities. + * their policies, and {@code teamId} so the caller's team can be loaded without scanning every + * team's rows. {@code owner} and {@code teamId} are plain values, not foreign keys, to stay + * decoupled from the security entities. */ @Entity @Table(name = "policies") @@ -43,6 +44,9 @@ public class PolicyEntity implements Serializable { @Column(name = "trigger_type") private String triggerType; + @Column(name = "team_id") + private Long teamId; + @Column(name = "policy_json", columnDefinition = "text") private String policyJson; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyRepository.java index ba6924f0f8..8cb36b73d8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyRepository.java @@ -3,6 +3,8 @@ package stirling.software.proprietary.policy.store; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository @@ -10,4 +12,14 @@ public interface PolicyRepository extends JpaRepository { /** Enabled policies of a given trigger type, for background triggers to activate. */ List findByTriggerTypeAndEnabledTrue(String triggerType); + + /** + * Policies belonging to a team, loaded without scanning every team's rows. A {@code null} + * teamId matches the rows with no team (login-disabled / pre-team data), mirroring the + * in-memory team filter rather than the empty result a plain {@code = null} would give. + */ + @Query( + "select p from PolicyEntity p where (:teamId is null and p.teamId is null) or" + + " p.teamId = :teamId") + List findByTeam(@Param("teamId") Long teamId); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java index c9a2a0ecf7..67796d4a67 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java @@ -15,6 +15,9 @@ public interface PolicyStore { List all(); + /** Policies owned by the given team, loaded scoped rather than fetched globally. */ + List findByTeam(Long teamId); + /** Enabled policies with the given trigger type, for background triggers. */ List findByTriggerType(String triggerType); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java index 864160d4b6..35c530fad0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java @@ -20,7 +20,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; -import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; @@ -31,6 +31,8 @@ import stirling.software.proprietary.policy.engine.PolicyRunner; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; import stirling.software.proprietary.policy.store.PolicyStore; /** @@ -46,7 +48,7 @@ import stirling.software.proprietary.policy.store.PolicyStore; @Slf4j @Service @RequiredArgsConstructor -@Profile("saas") +@ConditionalOnBooleanProperty(name = "policies.enabled") public class FolderWatchTrigger implements PolicyTrigger { private static final String TYPE = "folder-watch"; @@ -54,6 +56,7 @@ public class FolderWatchTrigger implements PolicyTrigger { private final PolicyStore policyStore; private final PolicyRunner policyRunner; private final List inputSources; + private final SourceStore sourceStore; private final ApplicationProperties applicationProperties; private final Map keysByDir = new ConcurrentHashMap<>(); @@ -120,6 +123,14 @@ public class FolderWatchTrigger implements PolicyTrigger { dirByKey.clear(); } + @Override + public void onPoliciesChanged() { + // A created/updated/deleted policy may add or drop a watched directory: register/cancel now + // instead of waiting up to watchReconcileSeconds for the next reconcile. A no-op until the + // trigger is started (watchService null), where the first reconcile picks everything up. + syncRegistrations(); + } + private void watchLoop() { // Capture once: stop() may null the field; close() still wakes take()/poll() on this local. WatchService watcher = watchService; @@ -269,12 +280,17 @@ public class FolderWatchTrigger implements PolicyTrigger { // the path was configured. private List watchDirsOf(Policy policy) { List dirs = new ArrayList<>(); - for (InputSpec spec : policy.sources()) { - InputSource source = sourceFor(spec); + for (String sourceId : policy.sourceIds()) { + Source source = sourceStore.get(sourceId).orElse(null); if (source == null) { continue; } - for (Path dir : source.watchTargets(spec)) { + InputSpec spec = source.toInputSpec(); + InputSource inputSource = sourceFor(spec); + if (inputSource == null) { + continue; + } + for (Path dir : inputSource.watchTargets(spec)) { dirs.add(dir.toAbsolutePath().normalize()); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java index a9cb363719..a97a9ac880 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java @@ -20,4 +20,12 @@ public interface PolicyTrigger { default void start() {} default void stop() {} + + /** + * React to a policy being created, updated, or deleted. A trigger that caches per-policy state + * (folder-watch tracks which directories to watch) re-syncs it now, so a new policy is acted on + * immediately and a deleted one stops at once, rather than waiting for the next periodic sweep. + * Default no-op for triggers that read the store fresh on every fire. + */ + default void onPoliciesChanged() {} } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTriggerManager.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTriggerManager.java index c1175eeb92..7971e6b490 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTriggerManager.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTriggerManager.java @@ -2,8 +2,8 @@ package stirling.software.proprietary.policy.trigger; import java.util.List; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.context.SmartLifecycle; -import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; @@ -13,7 +13,7 @@ import lombok.extern.slf4j.Slf4j; @Slf4j @Service @RequiredArgsConstructor -@Profile("saas") +@ConditionalOnBooleanProperty(name = "policies.enabled") public class PolicyTriggerManager implements SmartLifecycle { private final List triggers; @@ -48,4 +48,26 @@ public class PolicyTriggerManager implements SmartLifecycle { public boolean isRunning() { return running; } + + /** + * Tell every trigger that the policy set changed so cached registrations refresh promptly + * instead of waiting for the next periodic reconcile. Best-effort and idempotent: a failing + * trigger is logged and the rest still run; a no-op before the subsystem has started. + */ + public void notifyPoliciesChanged() { + if (!running) { + return; + } + for (PolicyTrigger trigger : triggers) { + try { + trigger.onPoliciesChanged(); + } catch (RuntimeException e) { + log.error( + "Failed to refresh trigger '{}' after policy change: {}", + trigger.type(), + e.getMessage(), + e); + } + } + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java index 708ca8128b..3e747d7169 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java @@ -10,7 +10,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; -import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; @@ -32,7 +32,7 @@ import tools.jackson.databind.ObjectMapper; @Slf4j @Service @RequiredArgsConstructor -@Profile("saas") +@ConditionalOnBooleanProperty(name = "policies.enabled") public class ScheduleTrigger implements PolicyTrigger { private static final String TYPE = "schedule"; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java index 454df5f67e..3c713206de 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java @@ -32,14 +32,16 @@ import stirling.software.common.model.exception.UnsupportedProviderException; "stirling.software.proprietary.repository", "stirling.software.proprietary.storage.repository", "stirling.software.proprietary.workflow.repository", - "stirling.software.proprietary.policy.store" + "stirling.software.proprietary.policy.store", + "stirling.software.proprietary.policy.source" }) @EntityScan({ "stirling.software.proprietary.security.model", "stirling.software.proprietary.model", "stirling.software.proprietary.storage.model", "stirling.software.proprietary.workflow.model", - "stirling.software.proprietary.policy.store" + "stirling.software.proprietary.policy.store", + "stirling.software.proprietary.policy.source" }) public class DatabaseConfig { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/FolderAccessGuardTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/FolderAccessGuardTest.java index ea33319a8b..4859954e71 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/FolderAccessGuardTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/FolderAccessGuardTest.java @@ -17,6 +17,9 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.source.InProcessSourceStore; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; /** * Tests for {@link FolderAccessGuard}: folder access is fail-closed, confined to the configured @@ -26,12 +29,14 @@ class FolderAccessGuardTest { @TempDir Path tempDir; + private final SourceStore sourceStore = new InProcessSourceStore(); + private FolderAccessGuard guard(List allowedRoots, String... activeProfiles) { ApplicationProperties properties = new ApplicationProperties(); properties.getPolicies().setAllowedFolderRoots(allowedRoots); StandardEnvironment environment = new StandardEnvironment(); environment.setActiveProfiles(activeProfiles); - return new FolderAccessGuard(properties, environment); + return new FolderAccessGuard(properties, environment, sourceStore); } @Test @@ -93,7 +98,23 @@ class FolderAccessGuardTest { assertFalse(guard.usesFolderAccess(policy(List.of(), OutputSpec.inline()))); } - private static Policy policy(List sources, OutputSpec output) { - return new Policy("p1", "p", "owner", true, null, sources, List.of(), output); + private Policy policy(List sources, OutputSpec output) { + List sourceIds = + sources.stream() + .map( + spec -> + sourceStore + .save( + new Source( + null, + "src", + spec.type(), + spec.options(), + true, + "owner", + null)) + .id()) + .toList(); + return new Policy("p1", "p", "owner", true, null, sourceIds, List.of(), output); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/PolicyAccessGuardTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/PolicyAccessGuardTest.java index 9f86b87aec..35c4d2631e 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/PolicyAccessGuardTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/PolicyAccessGuardTest.java @@ -17,6 +17,8 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.UserServiceInterface; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.store.InProcessPolicyStore; +import stirling.software.proprietary.policy.store.PolicyStore; /** * {@link PolicyAccessGuard}: policies are scoped to the caller's team. A user sees/accesses only @@ -36,18 +38,27 @@ class PolicyAccessGuardTest { } @Test - void visibleFiltersToTheCallersTeam() { + void visibleFromLoadsOnlyTheCallersTeam() { when(policyManagementAuthority.currentUserTeamId()).thenReturn(1L); - List all = List.of(inTeam(1L), inTeam(2L), inTeam(1L), inTeam(null)); - List visible = guard(true).visible(all); + PolicyStore store = new InProcessPolicyStore(); + store.save(inTeam(1L)); + store.save(inTeam(2L)); + store.save(inTeam(1L)); + store.save(inTeam(null)); + + List visible = guard(true).visibleFrom(store); + assertEquals(2, visible.size()); assertTrue(visible.stream().allMatch(p -> Long.valueOf(1L).equals(p.teamId()))); } @Test - void visibleReturnsEverythingWhenLoginDisabled() { - List all = List.of(inTeam(1L), inTeam(2L)); - assertEquals(all, guard(false).visible(all)); + void visibleFromReturnsEverythingWhenLoginDisabled() { + PolicyStore store = new InProcessPolicyStore(); + store.save(inTeam(1L)); + store.save(inTeam(2L)); + + assertEquals(2, guard(false).visibleFrom(store).size()); } @Test @@ -79,6 +90,6 @@ class PolicyAccessGuardTest { private static Policy inTeam(Long teamId) { return new Policy( - "p1", "p", "owner", true, null, List.of(), List.of(), OutputSpec.inline(), teamId); + null, "p", "owner", true, null, List.of(), List.of(), OutputSpec.inline(), teamId); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java index 9325c7de51..98ed101246 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java @@ -40,6 +40,9 @@ import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.PolicyRun; import stirling.software.proprietary.policy.model.PolicyRunView; 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.trigger.PolicyTriggerManager; @ExtendWith(MockitoExtension.class) @DisplayName("PolicyController") @@ -48,9 +51,12 @@ class PolicyControllerTest { @Mock private PolicyRunner policyRunner; @Mock private PolicyRunRegistry runRegistry; @Mock private stirling.software.proprietary.policy.store.PolicyStore policyStore; + @Mock private SourceStore sourceStore; + @Mock private SourceAccessGuard sourceAccessGuard; @Mock private PolicyValidator policyValidator; @Mock private PolicyAccessGuard policyAccessGuard; @Mock private PolicyManagementAuthority policyManagementAuthority; + @Mock private PolicyTriggerManager policyTriggerManager; @Mock private TempFileManager tempFileManager; @Mock private JobOwnershipService jobOwnershipService; @@ -65,9 +71,12 @@ class PolicyControllerTest { policyRunner, runRegistry, policyStore, + sourceStore, + sourceAccessGuard, policyValidator, policyAccessGuard, policyManagementAuthority, + policyTriggerManager, applicationProperties, tempFileManager, jobOwnershipService); @@ -214,6 +223,7 @@ class PolicyControllerTest { assertThat(response.getBody().owner()).isEqualTo("alice"); assertThat(response.getBody().teamId()).isEqualTo(7L); verify(policyValidator).validate(any()); + verify(policyTriggerManager).notifyPoliciesChanged(); } @Test @@ -229,6 +239,7 @@ class PolicyControllerTest { assertThat(((ResponseStatusException) e).getStatusCode()) .isEqualTo(HttpStatus.FORBIDDEN)); verify(policyStore, never()).save(any()); + verify(policyTriggerManager, never()).notifyPoliciesChanged(); } @Test @@ -295,8 +306,7 @@ class PolicyControllerTest { @DisplayName("listPolicies returns team-visible policies") void listVisible() { List all = List.of(policy("a", 1L), policy("b", 1L)); - when(policyStore.all()).thenReturn(all); - when(policyAccessGuard.visible(all)).thenReturn(all); + when(policyAccessGuard.visibleFrom(policyStore)).thenReturn(all); List result = controller.listPolicies(); @@ -355,6 +365,7 @@ class PolicyControllerTest { ResponseEntity response = controller.deletePolicy("a"); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + verify(policyTriggerManager).notifyPoliciesChanged(); } @Test @@ -369,6 +380,7 @@ class PolicyControllerTest { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); verify(policyStore, never()).delete(any()); + verify(policyTriggerManager, never()).notifyPoliciesChanged(); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java index 2cefb1b27c..cff169581b 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java @@ -33,6 +33,9 @@ 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.progress.PolicyProgressListener; +import stirling.software.proprietary.policy.source.InProcessSourceStore; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; /** * Tests for {@link PolicyRunner}: the one place that turns a policy's sources into runs. Verifies @@ -45,11 +48,12 @@ class PolicyRunnerTest { @Mock private PolicyEngine policyEngine; @Mock private InputSource folderSource; + private final SourceStore sourceStore = new InProcessSourceStore(); private PolicyRunner runner; @BeforeEach void setUp() { - runner = new PolicyRunner(policyEngine, List.of(folderSource)); + runner = new PolicyRunner(policyEngine, List.of(folderSource), sourceStore); } @Test @@ -145,15 +149,22 @@ class PolicyRunnerTest { verifyNoInteractions(folderSource); } - private static Policy policy(List sources) { + /** Persists each spec as a source and returns a policy referencing them by id. */ + private Policy policy(List sources) { + List sourceIds = + sources.stream().map(spec -> sourceStore.save(sourceFrom(spec)).id()).toList(); return new Policy( "p1", "p", "owner", true, null, - sources, + sourceIds, List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline()); } + + private static Source sourceFrom(InputSpec spec) { + return new Source(null, "src", spec.type(), spec.options(), true, "owner", null); + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java index edaa586511..8cdb1b45a3 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java @@ -23,6 +23,9 @@ import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.TriggerConfig; import stirling.software.proprietary.policy.output.PolicyOutputSink; +import stirling.software.proprietary.policy.source.InProcessSourceStore; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; import stirling.software.proprietary.policy.trigger.PolicyTrigger; /** Tests for {@link PolicyValidator}: routes each facet to its handler and surfaces failures. */ @@ -33,12 +36,14 @@ class PolicyValidatorTest { @Mock private InputSource inputSource; @Mock private PolicyOutputSink outputSink; + private final SourceStore sourceStore = new InProcessSourceStore(); private PolicyValidator validator; @BeforeEach void setUp() { validator = - new PolicyValidator(List.of(trigger), List.of(inputSource), List.of(outputSink)); + new PolicyValidator( + List.of(trigger), List.of(inputSource), List.of(outputSink), sourceStore); } @Test @@ -51,7 +56,7 @@ class PolicyValidatorTest { validator.validate(policy); verify(trigger).validate(policy); - verify(inputSource).validate(policy.sources().get(0)); + verify(inputSource).validate(InputSpec.folder("/in")); verify(outputSink).validate(policy.output()); } @@ -88,27 +93,35 @@ class PolicyValidatorTest { assertTrue(ex.getMessage().contains("unknown trigger type")); } - private static Policy policy(String triggerType) { + private Policy policy(String triggerType) { return new Policy( "p1", "p", "owner", true, new TriggerConfig(triggerType, Map.of()), - List.of(InputSpec.folder("/in")), + List.of(folderSourceId()), List.of(), OutputSpec.inline()); } - private static Policy manualOnly() { + private Policy manualOnly() { return new Policy( "p1", "p", "owner", true, null, - List.of(InputSpec.folder("/in")), + List.of(folderSourceId()), List.of(), OutputSpec.inline()); } + + /** Persists a folder source ("/in") and returns its id for a policy to reference. */ + private String folderSourceId() { + InputSpec spec = InputSpec.folder("/in"); + return sourceStore + .save(new Source(null, "src", spec.type(), spec.options(), true, "owner", null)) + .id(); + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/FolderInputSourceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/FolderInputSourceTest.java index 3e9d34ccf0..62bfa3aabb 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/FolderInputSourceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/FolderInputSourceTest.java @@ -25,6 +25,7 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.common.util.FileReadinessChecker; import stirling.software.proprietary.policy.config.FolderAccessGuard; import stirling.software.proprietary.policy.model.InputSpec; +import stirling.software.proprietary.policy.source.InProcessSourceStore; /** Tests for {@link FolderInputSource}: consume (claim + route) and snapshot (read-only) modes. */ @ExtendWith(MockitoExtension.class) @@ -40,7 +41,9 @@ class FolderInputSourceTest { void setUp() { ApplicationProperties properties = new ApplicationProperties(); properties.getPolicies().setAllowedFolderRoots(List.of(tempDir.toString())); - FolderAccessGuard guard = new FolderAccessGuard(properties, new StandardEnvironment()); + FolderAccessGuard guard = + new FolderAccessGuard( + properties, new StandardEnvironment(), new InProcessSourceStore()); source = new FolderInputSource(readinessChecker, guard); // Lenient: the missing-dir / nonexistent-dir cases return before any readiness check. lenient().when(readinessChecker.isReady(any())).thenReturn(true); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/FolderOutputSinkTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/FolderOutputSinkTest.java index 0d714162d7..f1b8b599ac 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/FolderOutputSinkTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/FolderOutputSinkTest.java @@ -22,6 +22,7 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.job.ResultFile; import stirling.software.proprietary.policy.config.FolderAccessGuard; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.source.InProcessSourceStore; /** Tests for {@link FolderOutputSink}: outputs are written to the configured directory on disk. */ class FolderOutputSinkTest { @@ -34,7 +35,10 @@ class FolderOutputSinkTest { void setUp() { ApplicationProperties properties = new ApplicationProperties(); properties.getPolicies().setAllowedFolderRoots(List.of(tempDir.toString())); - sink = new FolderOutputSink(new FolderAccessGuard(properties, new StandardEnvironment())); + sink = + new FolderOutputSink( + new FolderAccessGuard( + properties, new StandardEnvironment(), new InProcessSourceStore())); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/JpaSourceStoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/JpaSourceStoreTest.java new file mode 100644 index 0000000000..c5f8c9c936 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/JpaSourceStoreTest.java @@ -0,0 +1,114 @@ +package stirling.software.proprietary.policy.source; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +/** + * Tests for {@link JpaSourceStore}'s entity mapping. The repository is mocked; real Hibernate/H2 + * persistence is exercised at application boot, mirroring {@link + * stirling.software.proprietary.policy.store.JpaPolicyStore}'s test convention. + */ +@ExtendWith(MockitoExtension.class) +class JpaSourceStoreTest { + + @Mock private SourceRepository repository; + + private final ObjectMapper objectMapper = JsonMapper.builder().build(); + private JpaSourceStore store; + + @BeforeEach + void setUp() { + store = new JpaSourceStore(repository, objectMapper); + } + + @Test + void saveAssignsAnIdAndPersistsTheSourceAsJson() { + Source saved = + store.save( + new Source( + null, + "Claims intake", + "folder", + Map.of("directory", "/in/claims"), + true, + "alice", + 7L)); + + assertNotNull(saved.id()); + ArgumentCaptor captor = ArgumentCaptor.forClass(SourceEntity.class); + verify(repository).save(captor.capture()); + SourceEntity entity = captor.getValue(); + assertEquals(saved.id(), entity.getId()); + assertEquals("folder", entity.getType()); + assertEquals("alice", entity.getOwner()); + assertEquals(Long.valueOf(7L), entity.getTeamId()); + assertTrue(entity.isEnabled()); + // The stored JSON round-trips back to an equal source. + assertEquals(saved, objectMapper.readValue(entity.getSourceJson(), Source.class)); + } + + @Test + void getDeserializesTheSourceFromJson() { + Source source = + new Source("s1", "Claims", "folder", Map.of("directory", "/in"), true, "alice", 1L); + when(repository.findById("s1")).thenReturn(Optional.of(entityFor(source))); + + assertEquals(source, store.get("s1").orElseThrow()); + } + + @Test + void allDeserializesEverySource() { + Source a = new Source("a", "A", "folder", Map.of("directory", "/a"), true, "alice", 1L); + when(repository.findAll()).thenReturn(List.of(entityFor(a))); + + assertEquals(List.of(a), store.all()); + } + + @Test + void findByTeamDelegatesToTheScopedQuery() { + Source mine = new Source("a", "A", "folder", Map.of("directory", "/a"), true, "alice", 7L); + when(repository.findByTeam(7L)).thenReturn(List.of(entityFor(mine))); + + assertEquals(List.of(mine), store.findByTeam(7L)); + } + + @Test + void deleteReturnsWhetherTheSourceExisted() { + when(repository.existsById("s1")).thenReturn(true); + assertTrue(store.delete("s1")); + verify(repository).deleteById("s1"); + + when(repository.existsById("missing")).thenReturn(false); + assertFalse(store.delete("missing")); + } + + private SourceEntity entityFor(Source source) { + SourceEntity entity = new SourceEntity(); + entity.setId(source.id()); + entity.setName(source.name()); + entity.setType(source.type()); + entity.setOwner(source.owner()); + entity.setTeamId(source.teamId()); + entity.setEnabled(source.enabled()); + entity.setSourceJson(objectMapper.writeValueAsString(source)); + return entity; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java new file mode 100644 index 0000000000..c01df84a55 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java @@ -0,0 +1,123 @@ +package stirling.software.proprietary.policy.source; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +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 org.springframework.http.ResponseEntity; +import org.springframework.web.server.ResponseStatusException; + +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.input.InputSource; +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.store.InProcessPolicyStore; +import stirling.software.proprietary.policy.store.PolicyStore; +import stirling.software.proprietary.policy.trigger.PolicyTriggerManager; + +/** + * Tests for {@link SourceController}'s delete guard: a source still referenced by a policy is + * protected (409), while an unreferenced one is removed. Login is disabled, so editing and team + * scoping pass through and the reference check is exercised on its own. + */ +class SourceControllerTest { + + private final SourceStore sourceStore = new InProcessSourceStore(); + private final PolicyStore policyStore = new InProcessPolicyStore(); + private PolicyTriggerManager triggerManager; + private SourceController controller; + + @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); + SourceOverviewService overviewService = + new SourceOverviewService(sourceStore, policyStore, sourceGuard, policyGuard); + triggerManager = mock(PolicyTriggerManager.class); + // A permissive input source so config validation passes and save can be exercised. + InputSource folderInput = mock(InputSource.class); + when(folderInput.supports(any())).thenReturn(true); + controller = + new SourceController( + sourceStore, + sourceGuard, + overviewService, + policyStore, + policyGuard, + authority, + triggerManager, + properties, + List.of(folderInput)); + } + + @Test + void deletingAReferencedSourceConflicts() { + Source source = sourceStore.save(folderSource()); + policyStore.save(policyReferencing("Redact incoming", source.id())); + + ResponseStatusException ex = + assertThrows(ResponseStatusException.class, () -> controller.delete(source.id())); + + assertEquals(409, ex.getStatusCode().value()); + assertTrue(sourceStore.get(source.id()).isPresent()); + } + + @Test + void deletingAnUnreferencedSourceSucceeds() { + Source source = sourceStore.save(folderSource()); + + ResponseEntity response = controller.delete(source.id()); + + assertEquals(204, response.getStatusCode().value()); + assertTrue(sourceStore.get(source.id()).isEmpty()); + // A deletable source is referenced by no policy, so no watch registration can change. + verify(triggerManager, never()).notifyPoliciesChanged(); + } + + @Test + void savingASourceReSyncsTriggerRegistrations() { + controller.save(folderSource()); + + verify(triggerManager).notifyPoliciesChanged(); + } + + @Test + void deletingAMissingSourceIsNotFound() { + assertEquals(404, controller.delete("nope").getStatusCode().value()); + } + + private static Source folderSource() { + return new Source( + null, "Claims intake", "folder", Map.of("directory", "/in"), true, "owner", null); + } + + private static Policy policyReferencing(String name, String sourceId) { + return new Policy( + null, + name, + "owner", + true, + null, + List.of(sourceId), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.inline()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java new file mode 100644 index 0000000000..986fd8c636 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java @@ -0,0 +1,187 @@ +package stirling.software.proprietary.policy.source; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +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.store.InProcessPolicyStore; +import stirling.software.proprietary.policy.store.PolicyStore; + +/** + * Tests for {@link SourceOverviewService}: each source appears exactly once, annotated with the + * policies that reference it. Login is disabled, so the team guards pass everything through and the + * reference counting is exercised directly. + */ +class SourceOverviewServiceTest { + + private final SourceStore sourceStore = new InProcessSourceStore(); + private final PolicyStore policyStore = new InProcessPolicyStore(); + private SourceOverviewService 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 SourceOverviewService(sourceStore, policyStore, sourceGuard, policyGuard); + } + + @Test + void eachSourceAppearsOnceWithItsReferenceCount() { + Source a = source("A", "/a"); + Source b = source("B", "/b"); + Source c = source("C unused", "/c"); + policyReferencing("P1", a.id()); + policyReferencing("P2", a.id(), b.id()); + + SourcesResponse response = service.overview(); + + assertEquals(3, response.sources().size()); + // Sorted most-referenced first, so the shared source A leads. + assertEquals(a.id(), response.sources().get(0).id()); + + SourceView av = find(response, a.id()); + assertEquals(2, av.referenceCount()); + assertEquals("active", av.status()); + assertTrue( + av.referencingPolicies().stream() + .map(SourceView.PolicyRef::name) + .toList() + .containsAll(List.of("P1", "P2"))); + assertTrue( + av.config().stream() + .anyMatch( + row -> + row.label().equals("Directory") + && row.value().equals("/a"))); + + assertEquals(1, find(response, b.id()).referenceCount()); + + SourceView cv = find(response, c.id()); + assertEquals(0, cv.referenceCount()); + assertEquals("unused", cv.status()); + + // KPI strip: total, in-use, orphaned. + assertEquals(List.of(3L, 2L, 1L), response.kpis().stream().map(SourceKpi::value).toList()); + } + + @Test + void aDisabledSourceReadsAsDisabled() { + Source disabled = + sourceStore.save( + new Source( + null, + "Paused", + "folder", + Map.of("directory", "/d"), + false, + "owner", + null)); + + assertEquals("disabled", find(service.overview(), disabled.id()).status()); + } + + @Test + void overviewLoadsOnlyTheCallersTeam() { + // Login on, caller is on team 1. Another team's source and policy must be invisible, and a + // cross-team policy referencing our source must not inflate its reference count. + 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); + SourceOverviewService scoped = + new SourceOverviewService(sourceStore, policyStore, sourceGuard, policyGuard); + + Source ours = teamSource("Ours", "/ours", 1L); + teamSource("Theirs", "/theirs", 2L); + teamPolicy("Our policy", 1L, ours.id()); + teamPolicy("Their policy", 2L, ours.id()); + + SourcesResponse response = scoped.overview(); + + assertEquals(1, response.sources().size()); + SourceView view = response.sources().get(0); + assertEquals(ours.id(), view.id()); + assertEquals(1, view.referenceCount()); + assertEquals(List.of(1L, 1L, 0L), response.kpis().stream().map(SourceKpi::value).toList()); + } + + @Test + void documentVolumeIsNotTrackedYet() { + Source a = source("A", "/a"); + assertNull(find(service.overview(), a.id()).docsTotal()); + } + + 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 policyReferencing(String name, 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())); + } + + 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 SourceView find(SourcesResponse response, String id) { + return response.sources().stream() + .filter(view -> view.id().equals(id)) + .findFirst() + .orElseThrow(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java index 2bdbc08a79..e9667f2e8d 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java @@ -18,7 +18,6 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; @@ -55,7 +54,7 @@ class JpaPolicyStoreTest { "alice", true, new TriggerConfig("schedule", Map.of()), - List.of(InputSpec.folder("/in")), + List.of("src-in"), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline())); @@ -88,6 +87,46 @@ class JpaPolicyStoreTest { assertEquals(policy, store.get("p1").orElseThrow()); } + @Test + void saveDenormalizesTeamIdForScopedQueries() { + store.save( + new Policy( + "p1", + "scoped", + "alice", + true, + null, + List.of(), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.inline(), + 9L)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PolicyEntity.class); + verify(repository).save(captor.capture()); + assertEquals(Long.valueOf(9L), captor.getValue().getTeamId()); + } + + @Test + void findByTeamDelegatesToTheScopedQuery() { + Policy policy = + new Policy( + "p1", + "ours", + "alice", + true, + null, + List.of(), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.inline(), + 9L); + when(repository.findByTeam(9L)).thenReturn(List.of(entityFor(policy))); + + List mine = store.findByTeam(9L); + + assertEquals(1, mine.size()); + assertEquals("p1", mine.get(0).id()); + } + @Test void findByTriggerTypeUsesTheEnabledQuery() { Policy policy = @@ -125,6 +164,7 @@ class JpaPolicyStoreTest { entity.setOwner(policy.owner()); entity.setEnabled(policy.enabled()); entity.setTriggerType(policy.trigger() == null ? null : policy.trigger().type()); + entity.setTeamId(policy.teamId()); entity.setPolicyJson(objectMapper.writeValueAsString(policy)); return entity; } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/FolderWatchTriggerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/FolderWatchTriggerTest.java index 9b0b9f1d2a..58c5123327 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/FolderWatchTriggerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/FolderWatchTriggerTest.java @@ -32,6 +32,9 @@ 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.SourceStore; import stirling.software.proprietary.policy.store.PolicyStore; /** @@ -50,6 +53,7 @@ class FolderWatchTriggerTest { @TempDir Path tempDir; + private final SourceStore sourceStore = new InProcessSourceStore(); private FolderWatchTrigger trigger; @BeforeEach @@ -59,6 +63,7 @@ class FolderWatchTriggerTest { policyStore, policyRunner, List.of(folderSource), + sourceStore, new ApplicationProperties()); lenient().when(folderSource.supports(any())).thenReturn(true); lenient() @@ -160,18 +165,59 @@ class FolderWatchTriggerTest { } } + @Test + void onPoliciesChangedSyncsRegistrationsImmediately() throws Exception { + Path dir = Files.createDirectories(tempDir.resolve("watched")); + Policy p = folderWatch("p", List.of(InputSpec.folder(dir.toString()))); + + WatchService service = FileSystems.getDefault().newWatchService(); + try { + trigger.watchService = service; + when(policyStore.findByTriggerType("folder-watch")).thenReturn(List.of(p)); + + // The mutation hook registers the new policy's directory without waiting for a + // reconcile. + trigger.onPoliciesChanged(); + assertEquals(Set.of(normalized(dir.toString())), trigger.watchedDirs()); + + // Once the policy is gone, the same hook cancels its registration. + when(policyStore.findByTriggerType("folder-watch")).thenReturn(List.of()); + trigger.onPoliciesChanged(); + assertEquals(Set.of(), trigger.watchedDirs()); + } finally { + service.close(); + } + } + private static Path normalized(String dir) { return Path.of(dir).toAbsolutePath().normalize(); } - private static Policy folderWatch(String id, List sources) { + /** Persists each spec as a source and returns a folder-watch policy referencing them by id. */ + private Policy folderWatch(String id, List sources) { + List sourceIds = + sources.stream() + .map( + spec -> + sourceStore + .save( + new Source( + null, + "src", + spec.type(), + spec.options(), + true, + "owner", + null)) + .id()) + .toList(); return new Policy( id, "watcher", "owner", true, new TriggerConfig("folder-watch", Map.of()), - sources, + sourceIds, List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline()); } diff --git a/app/saas/src/main/resources/application-saas.properties b/app/saas/src/main/resources/application-saas.properties index 63ba016950..1984974fef 100644 --- a/app/saas/src/main/resources/application-saas.properties +++ b/app/saas/src/main/resources/application-saas.properties @@ -65,6 +65,10 @@ supabase.url=https://${app.supabase.project-ref}.supabase.co spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://${app.supabase.project-ref}.supabase.co/auth/v1/.well-known/jwks.json spring.security.oauth2.resourceserver.jwt.audiences=${app.supabase.expected-aud} +# ---------- Policies ---------- +# Exposes the /api/v1/policies and /api/v1/sources controllers, engine, stores, and triggers. +policies.enabled=true + # ---------- Multi-tenant scoping ---------- # Restrict the signing user picker to the caller's team; SaaS must be 'team' # or unrelated tenants leak emails to each other. diff --git a/app/saas/src/main/resources/db/migration/saas/V22__policy_engine_tables.sql b/app/saas/src/main/resources/db/migration/saas/V22__policy_engine_tables.sql new file mode 100644 index 0000000000..babdaef562 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V22__policy_engine_tables.sql @@ -0,0 +1,35 @@ +-- Policy engine schema (gated by policies.enabled): persisted policies and the reusable input +-- connections ("sources") they reference by id. The whole policy/source lives as JSON in the +-- *_json column (authoritative on read); the scalar columns are denormalized copies for querying, +-- notably team_id so a caller's team can be loaded without scanning every team's rows. owner and +-- team_id are plain values, not foreign keys, to stay decoupled from the security entities (so this +-- subsystem can be enabled or disabled without touching them). Hibernate ddl-auto would also create +-- these, but this keeps the schema explicit for the Flyway-managed deployments. + +CREATE TABLE IF NOT EXISTS policies ( + id VARCHAR(255) PRIMARY KEY, + name VARCHAR(255), + owner VARCHAR(255), + enabled BOOLEAN NOT NULL DEFAULT FALSE, + trigger_type VARCHAR(255), + team_id BIGINT, + policy_json TEXT +); + +-- For deployments where Hibernate already created policies before this migration (pre-team_id). +ALTER TABLE policies ADD COLUMN IF NOT EXISTS team_id BIGINT; + +CREATE INDEX IF NOT EXISTS idx_policies_team ON policies (team_id); +CREATE INDEX IF NOT EXISTS idx_policies_trigger ON policies (trigger_type, enabled); + +CREATE TABLE IF NOT EXISTS policy_sources ( + id VARCHAR(255) PRIMARY KEY, + name VARCHAR(255), + type VARCHAR(255), + owner VARCHAR(255), + team_id BIGINT, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + source_json TEXT +); + +CREATE INDEX IF NOT EXISTS idx_policy_sources_team ON policy_sources (team_id); diff --git a/frontend/portal/public/locales/en-US/translation.toml b/frontend/portal/public/locales/en-US/translation.toml index 4488dd6726..1aa24bd89e 100644 --- a/frontend/portal/public/locales/en-US/translation.toml +++ b/frontend/portal/public/locales/en-US/translation.toml @@ -341,8 +341,8 @@ title = "No drift detected" description = "Every document in the last 24h matched its inferred schema." [sources] -title = "Sources & Agents" -subtitle = "Every place documents flow into Stirling — agents, API clients, webhooks, connectors and more. Click a row for type-specific detail." +title = "Sources" +subtitle = "Reusable input connections that feed documents into Stirling. Configure a connection once, then reference it from any number of policies. Click a row for its config and which policies use it." [sources.actions] agentBuilder = "Agent Builder" @@ -350,86 +350,75 @@ connectSource = "Connect source" [sources.empty] title = "No sources connected yet" -description = "Connect an agent, API client, webhook, connector or inbox to start feeding documents into your pipelines." +description = "Connect a folder (and, soon, cloud storage) so your policies have somewhere to pull documents from." [sources.kpi] -agentsActive = "Agents active" -scenarios = "Scenarios" -evalPassRate = "Eval pass rate (7d)" -docs24h = "Docs / 24h" +total = "Connections" +inUse = "In use" +unused = "Unused" + +[sources.status] +active = "Active" +unused = "Unused" +disabled = "Disabled" [sources.table] source = "Source" status = "Status" -docs24h = "Docs / 24h" -docs30d = "Docs / 30d" -lastEvent = "Last event" -owner = "Owner" +usedBy = "Policies" [sources.detail] -ownedBy = "{{type}} · owned by {{owner}}" +subtitle = "{{type}} · {{status}}" closeAriaLabel = "Close detail" +usedBy = "Used by" +notReferenced = "Not referenced by any policy, so it's safe to delete." +docsUntracked = "Per-source document volume isn't tracked yet." +edit = "Edit" +pause = "Pause" +resume = "Resume" +delete = "Delete source" -[sources.agent] -model = "Model" -calls24h = "Calls / 24h" -errorRate = "Error rate" -escalations24h = "Escalations / 24h" -meanConfidence = "Mean confidence" -meanOutputConfidence = "Mean output confidence" -assignedPipelines = "Assigned pipelines" -scopes = "Scopes" -viewEvalRuns = "View eval runs" -pauseAgent = "Pause agent" - -[sources.apiClient] -secretKey = "Secret key" -rateLimit = "Rate limit" -createdBy = "Created by" -lastRotated = "Last rotated" -rateLimitWindow = "Rate-limit window" -usedPct = "{{pct}} used" -rateLimitUsage = "Rate-limit usage" -topEndpoints = "Top endpoints" -callsPer24h = "{{count}} / 24h" -rotateKey = "Rotate key" -revoke = "Revoke" - -[sources.webhook] -endpointUrl = "Endpoint URL" -authType = "Auth type" -successRate = "Success rate" -retries24h = "Retries / 24h" -recentDeliveries = "Recent deliveries" -sendTestEvent = "Send test event" -viewSigningSecret = "View signing secret" +[sources.delete] +title = "Delete source?" +body = "Delete \"{{name}}\"? This can't be undone. Policies that reference it would need to be updated." +cancel = "Cancel" +confirm = "Delete" [sources.wizard] title = "Connect a source" +editTitle = "Edit source" subtitle = "Step {{current}} of {{total}} · {{label}}" cancel = "Cancel" back = "Back" continue = "Continue" -configureNote = "Scopes, rate limits and IP allowlists can be tuned after the source is connected." +save = "Save changes" +name = "Name" +namePlaceholder = "e.g. Claims intake" type = "Type" -defaultPipeline = "Default pipeline" -defaultPipelineValue = "Redact & Flatten" -initialState = "Initial state" -initialStateValue = "Paused" -region = "Region" [sources.wizard.steps] chooseType = "Choose type" configure = "Configure" review = "Review & connect" -[sources.wizard.configureLead] -before = "Configure your" -after = ". Point it at Stirling and attach a default pipeline — every document this source ingests runs through it automatically." +[sources.types.folder] +label = "Folder" +description = "Watch a directory on the server for new documents." -[sources.wizard.reviewLead] -before = "Ready to connect a new" -after = ". It starts paused so you can verify the first few documents before going live." +[sources.types.folder.fields.directory] +label = "Directory path" +placeholder = "/data/incoming" +helperText = "Absolute path Stirling watches for files to process." + +[sources.types.folder.fields.mode] +label = "Read mode" + +[sources.types.folder.fields.mode.options] +consume = "Consume: process each file once" +snapshot = "Snapshot: re-read the folder every run" + +[sources.types.unknown] +label = "Source" [usage] title = "Usage & Billing" @@ -1780,3 +1769,8 @@ or = "or" [auth] loading = "Loading" redirectingToEditor = "Redirecting to the editor..." + +[errorBoundary] +title = "Something went wrong on this page" +description = "This view hit an unexpected error. Try again, or pick another section from the sidebar." +retry = "Try again" diff --git a/frontend/portal/src/App.tsx b/frontend/portal/src/App.tsx index c40029d0dc..a94a5c304b 100644 --- a/frontend/portal/src/App.tsx +++ b/frontend/portal/src/App.tsx @@ -1,7 +1,8 @@ import { useEffect, type ReactNode } from "react"; -import { BrowserRouter } from "react-router-dom"; +import { BrowserRouter, useLocation } from "react-router-dom"; import { MantineProvider } from "@mantine/core"; import { AuthProvider } from "@shared/auth"; +import { ErrorBoundary } from "@portal/components/ErrorBoundary"; import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext"; import { TierProvider } from "@portal/contexts/TierContext"; import { UIProvider, useUI } from "@portal/contexts/UIContext"; @@ -61,6 +62,20 @@ function SettingsHost() { return ; } +/** + * The routed view, wrapped in an error boundary so a single view crashing can't + * white-screen the portal (the shell + nav stay alive). Keyed by route so + * navigating to another section clears any error from the previous one. + */ +function RoutedContent() { + const { pathname } = useLocation(); + return ( + + + + ); +} + export function App() { // Honour the Vite base path so the portal routes correctly when served under a // subpath (e.g. "/portal" behind the single-origin proxy). BASE_URL is "./" @@ -78,7 +93,7 @@ export function App() { - + diff --git a/frontend/portal/src/api/http.ts b/frontend/portal/src/api/http.ts index 29e99dde7c..9e1619fe38 100644 --- a/frontend/portal/src/api/http.ts +++ b/frontend/portal/src/api/http.ts @@ -31,6 +31,24 @@ export class HttpError extends Error { } } +/** + * Best-effort human-readable message from a thrown error: unwraps an + * {@link HttpError}'s ProblemDetail-ish body (`detail` / `message` / `error`) + * before falling back to the error's own message. Shared by views that surface + * a failed request inline. + */ +export function errorMessage(error: unknown): string { + if (error instanceof HttpError) { + const body = error.body as { + detail?: string; + message?: string; + error?: string; + } | null; + return body?.detail ?? body?.message ?? body?.error ?? error.message; + } + return error instanceof Error ? error.message : String(error); +} + function authHeader(): Record { const token = getStoredToken(); return token ? { Authorization: `Bearer ${token}` } : {}; diff --git a/frontend/portal/src/api/sources.ts b/frontend/portal/src/api/sources.ts index cbc2c7cba6..29988958b5 100644 --- a/frontend/portal/src/api/sources.ts +++ b/frontend/portal/src/api/sources.ts @@ -1,25 +1,80 @@ import { httpJson } from "@portal/api/http"; -import type { SourcesResponse } from "@portal/mocks/sources"; -import type { Tier } from "@portal/contexts/TierContext"; -export type { - AgentDetail, - ApiClientDetail, - BasicDetail, - Source, - SourceDetail, - SourceStatus, - SourceType, - SourceTypeMeta, - SourcesKpi, - SourcesResponse, - WebhookDetail, -} from "@portal/mocks/sources"; -export { SOURCE_STATUS_TONE, SOURCE_TYPE_META } from "@portal/mocks/sources"; +/** + * Sources service layer: the backend contract. + */ -/** GET /v1/sources?tier=… — KPI strip + the sources table for the tier. */ -export async function fetchSources(tier: Tier): Promise { - return httpJson( - `/v1/sources?tier=${encodeURIComponent(tier)}`, - ); +/** Overview row status: referenced and enabled, enabled-but-orphaned, or disabled. */ +export type SourceStatus = "active" | "unused" | "disabled"; + +/** A policy that references a source. */ +export interface SourcePolicyRef { + id: string; + name: string; +} + +/** One key/value line summarising a source's config for display. */ +export interface SourceDetailRow { + label: string; + value: string; +} + +/** One row in the Sources overview table. Mirrors the backend `SourceView`. */ +export interface SourceView { + id: string; + name: string; + type: string; + status: SourceStatus; + referenceCount: number; + referencingPolicies: SourcePolicyRef[]; + config: SourceDetailRow[]; + /** Per-source document volume: not tracked yet (always null for now). */ + docsTotal: number | null; +} + +export interface SourceKpi { + value: number; + description: string; +} + +export interface SourcesResponse { + kpis: SourceKpi[]; + sources: SourceView[]; +} + +/** + * The wire record for a single source: the create/update body (`id` omitted on + * create) and what the backend returns from POST/GET. Mirrors the backend + * `Source` record; `owner`/`teamId` are stamped server-side. + */ +export interface Source { + id?: string; + name: string; + type: string; + options: Record; + enabled: boolean; + owner?: string | null; + teamId?: number | null; +} + +/** GET /api/v1/sources: KPI strip + one row per source for the admin. */ +export async function fetchSources(): Promise { + return httpJson("/api/v1/sources"); +} + +/** GET /api/v1/sources/{id}: the raw source record (config options), for editing. */ +export async function fetchSource(id: string): Promise { + return httpJson(`/api/v1/sources/${encodeURIComponent(id)}`); +} + +/** POST /api/v1/sources: create (blank id) or update (matched id) a source. */ +export async function createSource(source: Source): Promise { + return httpJson("/api/v1/sources", { method: "POST", body: source }); +} + +/** DELETE /api/v1/sources/{id}: remove a source (409 if a policy references it). */ +export async function deleteSource(id: string): Promise { + await httpJson(`/api/v1/sources/${encodeURIComponent(id)}`, { + method: "DELETE", + }); } diff --git a/frontend/portal/src/components/ErrorBoundary.test.tsx b/frontend/portal/src/components/ErrorBoundary.test.tsx new file mode 100644 index 0000000000..91c22effca --- /dev/null +++ b/frontend/portal/src/components/ErrorBoundary.test.tsx @@ -0,0 +1,64 @@ +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { ErrorBoundary } from "@portal/components/ErrorBoundary"; + +// Deterministic i18n: return the key so assertions don't depend on the async +// TOML backend ever loading. Mirrors the editor's test setup convention. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +function Boom(): never { + throw new Error("kaboom"); +} + +describe("ErrorBoundary", () => { + it("renders the default fallback when a child throws", () => { + // The boundary logs the caught error; silence it for a clean test run. + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + render( + + + , + ); + expect(screen.getByText("errorBoundary.title")).toBeInTheDocument(); + expect(screen.getByText("errorBoundary.description")).toBeInTheDocument(); + spy.mockRestore(); + }); + + it("recovers when retry is clicked and the child stops throwing", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + + // A child that throws once, then renders fine after an external toggle. + let shouldThrow = true; + function Toggleable() { + const [, force] = useState(0); + // Expose a way for the test to flip the flag and re-render is driven by + // ErrorBoundary remounting the subtree on reset. + if (shouldThrow) throw new Error("kaboom"); + void force; + return

recovered content
; + } + + render( + + + , + ); + + // Fallback is shown. + expect(screen.getByText("errorBoundary.title")).toBeInTheDocument(); + + // Stop throwing, then click retry to clear the boundary's error state. + shouldThrow = false; + fireEvent.click(screen.getByText("errorBoundary.retry")); + + expect(screen.getByText("recovered content")).toBeInTheDocument(); + expect(screen.queryByText("errorBoundary.title")).not.toBeInTheDocument(); + spy.mockRestore(); + }); +}); diff --git a/frontend/portal/src/components/ErrorBoundary.tsx b/frontend/portal/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000000..f97d208a31 --- /dev/null +++ b/frontend/portal/src/components/ErrorBoundary.tsx @@ -0,0 +1,68 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Button, EmptyState } from "@shared/components"; + +/** + * Default fallback for {@link ErrorBoundary}. Split into a function component so + * it can read translations via the `useTranslation` hook, which the class-based + * boundary cannot call directly. + */ +function DefaultErrorFallback({ onRetry }: { onRetry: () => void }) { + const { t } = useTranslation(); + return ( + {t("errorBoundary.retry")}} + /> + ); +} + +interface ErrorBoundaryProps { + children: ReactNode; + /** Custom fallback; receives a reset fn to retry the subtree. */ + fallback?: (reset: () => void) => ReactNode; +} + +interface ErrorBoundaryState { + error: Error | null; +} + +/** + * Contains render/runtime crashes to its subtree so one failing view can never + * white-screen the whole portal. The app shell + navigation live OUTSIDE this + * boundary and stay interactive, so the user can always navigate away; the + * boundary is keyed by route in App, so moving to another view clears the error. + * + * This is what makes running against a real backend (mocks off) safe: a page + * that gets an unexpected/!missing response degrades to a contained error card + * instead of taking the app down. + */ +export class ErrorBoundary extends Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + state: ErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + // Log for debugging; the UI itself stays contained by the fallback. + console.error("Portal view crashed:", error, info.componentStack); + } + + reset = (): void => this.setState({ error: null }); + + render(): ReactNode { + const { error } = this.state; + if (!error) return this.props.children; + if (this.props.fallback) return this.props.fallback(this.reset); + return ( +
+ +
+ ); + } +} diff --git a/frontend/portal/src/components/PolicySummary.tsx b/frontend/portal/src/components/PolicySummary.tsx index 971ef88e1d..57199bc8f7 100644 --- a/frontend/portal/src/components/PolicySummary.tsx +++ b/frontend/portal/src/components/PolicySummary.tsx @@ -125,7 +125,7 @@ export function PolicySummary() { }, ]; - const rows: PolicyRow[] = data?.catalogue.map(toRow) ?? []; + const rows: PolicyRow[] = data?.catalogue?.map(toRow) ?? []; return (
@@ -139,7 +139,7 @@ export function PolicySummary() { {t("policySummary.subtitle")}

- {data && ( + {data?.summary && ( {t("policySummary.activeSummary", { active: data.summary.active, diff --git a/frontend/portal/src/components/sources/AgentPanel.stories.tsx b/frontend/portal/src/components/sources/AgentPanel.stories.tsx deleted file mode 100644 index 5289d0c623..0000000000 --- a/frontend/portal/src/components/sources/AgentPanel.stories.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { AgentDetail } from "@portal/api/sources"; -import { AgentPanel } from "@portal/components/sources/AgentPanel"; - -const meta: Meta = { - title: "Portal/Sources/AgentPanel", - component: AgentPanel, - parameters: { layout: "padded" }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -const healthy: AgentDetail = { - kind: "agent", - model: "claude-sonnet-4.5", - calls24h: 1342, - errorRate: 0.004, - confidence: 0.962, - escalations24h: 11, - assignedPipelines: ["Invoice v3", "AP Routing"], - scopes: ["documents:read", "pipelines:invoke", "extract:write"], -}; - -export const Healthy: Story = { args: { d: healthy } }; - -/** Error rate over the 5% alarm threshold flips the badge and bar to danger/amber. */ -export const Degraded: Story = { - args: { - d: { - ...healthy, - model: "claude-opus-4.1", - errorRate: 0.071, - confidence: 0.883, - escalations24h: 34, - }, - }, -}; diff --git a/frontend/portal/src/components/sources/AgentPanel.tsx b/frontend/portal/src/components/sources/AgentPanel.tsx deleted file mode 100644 index a5a095bc9c..0000000000 --- a/frontend/portal/src/components/sources/AgentPanel.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { - Button, - Chip, - ProgressBar, - StatTile, - StatusBadge, -} from "@shared/components"; -import type { AgentDetail } from "@portal/api/sources"; -import { pct } from "@portal/components/sources/format"; -import "@portal/views/Sources.css"; - -export function AgentPanel({ d }: { d: AgentDetail }) { - const { t } = useTranslation(); - const errorTone = - d.errorRate >= 0.05 - ? "danger" - : d.errorRate >= 0.02 - ? "warning" - : "success"; - return ( -
-
- {d.model}} - /> - - - {pct(d.errorRate)} - - } - /> - -
- -
-
- {t("sources.agent.meanConfidence")} - {pct(d.confidence)} -
- = 0.93 ? "var(--color-green)" : "var(--color-amber)" - } - label={t("sources.agent.meanOutputConfidence")} - /> -
- -
- - {t("sources.agent.assignedPipelines")} - -
- {d.assignedPipelines.map((p) => ( - - {p} - - ))} -
-
- -
- - {t("sources.agent.scopes")} - -
- {d.scopes.map((s) => ( - - {s} - - ))} -
-
- - {/* TODO(backend): wire to GET /v1/sources/{id}/eval-runs and - POST /v1/sources/{id}/pause — currently inert demo controls. */} -
- - -
-
- ); -} diff --git a/frontend/portal/src/components/sources/ApiClientPanel.stories.tsx b/frontend/portal/src/components/sources/ApiClientPanel.stories.tsx deleted file mode 100644 index 7c67e90784..0000000000 --- a/frontend/portal/src/components/sources/ApiClientPanel.stories.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { ApiClientDetail } from "@portal/api/sources"; -import { ApiClientPanel } from "@portal/components/sources/ApiClientPanel"; - -const meta: Meta = { - title: "Portal/Sources/ApiClientPanel", - component: ApiClientPanel, - parameters: { layout: "padded" }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -const active: ApiClientDetail = { - kind: "apiclient", - maskedKey: "sk_live_••••••••••••4f9a", - rateLimit: "600 req/min", - rateUsedPct: 0.42, - endpoints: [ - { method: "POST", path: "/v1/extract", calls24h: 1820 }, - { method: "POST", path: "/v1/redact", calls24h: 740 }, - { method: "GET", path: "/v1/documents/{id}", calls24h: 380 }, - ], - createdBy: "you@acme.com", - lastRotated: "23 days ago", -}; - -export const Active: Story = { args: { d: active } }; - -/** Rate window near its ceiling pushes the thresholded bar into the warning band. */ -export const NearLimit: Story = { - args: { d: { ...active, rateUsedPct: 0.93 } }, -}; - -/** A revoked key: never rotated, no traffic, masked label flags the state. */ -export const Revoked: Story = { - args: { - d: { - ...active, - maskedKey: "sk_live_••••••••••••0000 (revoked)", - rateUsedPct: 0, - lastRotated: "never", - endpoints: active.endpoints.map((e) => ({ ...e, calls24h: 0 })), - }, - }, -}; diff --git a/frontend/portal/src/components/sources/ApiClientPanel.tsx b/frontend/portal/src/components/sources/ApiClientPanel.tsx deleted file mode 100644 index 332b18f949..0000000000 --- a/frontend/portal/src/components/sources/ApiClientPanel.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Button, Chip, ProgressBar, StatTile } from "@shared/components"; -import type { ApiClientDetail } from "@portal/api/sources"; -import { pct } from "@portal/components/sources/format"; -import "@portal/views/Sources.css"; - -export function ApiClientPanel({ d }: { d: ApiClientDetail }) { - const { t } = useTranslation(); - return ( -
-
- {d.maskedKey}} - /> - - - -
- -
-
- {t("sources.apiClient.rateLimitWindow")} - - {t("sources.apiClient.usedPct", { pct: pct(d.rateUsedPct) })} - -
- -
- -
- - {t("sources.apiClient.topEndpoints")} - -
- {d.endpoints.map((e) => ( -
- - {e.method} - - {e.path} - - {t("sources.apiClient.callsPer24h", { - count: e.calls24h.toLocaleString(), - })} - -
- ))} -
-
- - {/* TODO(backend): wire to POST /v1/sources/{id}/rotate-key and - DELETE /v1/sources/{id} — currently inert demo controls. */} -
- - -
-
- ); -} diff --git a/frontend/portal/src/components/sources/ConnectWizard.stories.tsx b/frontend/portal/src/components/sources/ConnectWizard.stories.tsx index a78fb0ef15..db647689b9 100644 --- a/frontend/portal/src/components/sources/ConnectWizard.stories.tsx +++ b/frontend/portal/src/components/sources/ConnectWizard.stories.tsx @@ -5,7 +5,7 @@ const meta: Meta = { title: "Portal/Sources/ConnectWizard", component: ConnectWizard, parameters: { layout: "fullscreen" }, - args: { open: true, onClose: () => {} }, + args: { open: true, onClose: () => {}, onCreated: () => {} }, }; export default meta; type Story = StoryObj; diff --git a/frontend/portal/src/components/sources/ConnectWizard.test.tsx b/frontend/portal/src/components/sources/ConnectWizard.test.tsx new file mode 100644 index 0000000000..b16b6fcc66 --- /dev/null +++ b/frontend/portal/src/components/sources/ConnectWizard.test.tsx @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { HttpError } from "@portal/api/http"; +import { ConnectWizard } from "@portal/components/sources/ConnectWizard"; + +// Deterministic i18n: keys come back verbatim so the test never waits on the +// async TOML backend. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +// Mock the service layer so no real fetch is issued. +const createSource = vi.fn(); +vi.mock("@portal/api/sources", () => ({ + createSource: (...args: unknown[]) => createSource(...args), +})); + +/** Step through the folder-source flow: choose type -> configure -> review. */ +function stepToReview() { + // Step 0: folder is the default-selected type. Continue. + fireEvent.click(screen.getByText("sources.wizard.continue")); + + // Step 1: fill the required name + directory fields. + const inputs = screen.getAllByRole("textbox"); + // First textbox is the name field, second is the folder's directory field. + fireEvent.change(inputs[0], { target: { value: "Claims intake" } }); + fireEvent.change(inputs[1], { target: { value: "/data/incoming" } }); + fireEvent.click(screen.getByText("sources.wizard.continue")); +} + +describe("ConnectWizard", () => { + beforeEach(() => { + createSource.mockReset(); + }); + + it("creates a folder source with the configured options", async () => { + createSource.mockResolvedValue({ id: "src-1" }); + const onCreated = vi.fn(); + const onClose = vi.fn(); + + render(); + + stepToReview(); + + // Step 2: submit via the final "connect source" action. + fireEvent.click(screen.getByText("sources.actions.connectSource")); + + await waitFor(() => { + expect(createSource).toHaveBeenCalledTimes(1); + }); + expect(createSource).toHaveBeenCalledWith({ + name: "Claims intake", + type: "folder", + options: { directory: "/data/incoming", mode: "consume" }, + enabled: true, + }); + await waitFor(() => { + expect(onCreated).toHaveBeenCalledTimes(1); + }); + }); + + it("edits an existing source: prefilled, skips type, submits with its id", async () => { + createSource.mockResolvedValue({ id: "s1" }); + const onCreated = vi.fn(); + + render( + , + ); + + // Edit opens on the configure step with name + directory prefilled. + const inputs = screen.getAllByRole("textbox") as HTMLInputElement[]; + expect(inputs[0].value).toBe("James"); + expect(inputs[1].value).toBe("'/data/in'"); + + // Fix the stray quotes, continue to review, save. + fireEvent.change(inputs[1], { target: { value: "/data/in" } }); + fireEvent.click(screen.getByText("sources.wizard.continue")); + fireEvent.click(screen.getByText("sources.wizard.save")); + + await waitFor(() => { + expect(createSource).toHaveBeenCalledTimes(1); + }); + expect(createSource).toHaveBeenCalledWith({ + id: "s1", + name: "James", + type: "folder", + options: { directory: "/data/in", mode: "consume" }, + enabled: true, + }); + await waitFor(() => { + expect(onCreated).toHaveBeenCalledTimes(1); + }); + }); + + it("renders the inline error message when create fails", async () => { + createSource.mockRejectedValue( + new HttpError(400, "Bad Request", { + detail: "Directory is not readable", + }), + ); + + render(); + + stepToReview(); + fireEvent.click(screen.getByText("sources.actions.connectSource")); + + expect( + await screen.findByText("Directory is not readable"), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/portal/src/components/sources/ConnectWizard.tsx b/frontend/portal/src/components/sources/ConnectWizard.tsx index 8bf677f27d..e0c9a3a6b7 100644 --- a/frontend/portal/src/components/sources/ConnectWizard.tsx +++ b/frontend/portal/src/components/sources/ConnectWizard.tsx @@ -1,166 +1,283 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Button, CodeBlock, Modal, StatTile } from "@shared/components"; -import { type Source, SOURCE_TYPE_META } from "@portal/api/sources"; +import { + Banner, + Button, + FormField, + Input, + Modal, + Select, + StatTile, +} from "@shared/components"; +import { errorMessage } from "@portal/api/http"; +import { createSource, type Source } from "@portal/api/sources"; +import { + CREATABLE_SOURCE_TYPES, + defaultOptions, + sourceTypeMeta, + type CreatableSourceType, +} from "@portal/components/sources/sourceTypes"; import "@portal/views/Sources.css"; -const WIZARD_STEP_COUNT = 3; +const DEFAULT_TYPE = CREATABLE_SOURCE_TYPES[0]; -const CONNECT_SNIPPET = `curl https://api.stirlingpdf.com/v1/extract \\ - -H "Authorization: Bearer sk_live_••••" \\ - -F "file=@invoice.pdf" \\ - -F "pipeline=invoice-v3"`; +/** Wizard steps. Editing skips type selection (the type is fixed once created). */ +type StepId = "type" | "configure" | "review"; +const CREATE_STEPS: StepId[] = ["type", "configure", "review"]; +const EDIT_STEPS: StepId[] = ["configure", "review"]; interface ConnectWizardProps { open: boolean; onClose: () => void; + /** Called after a source is created or updated so the page can refetch. */ + onCreated: () => void; + /** When set, the wizard edits this existing source instead of creating one. */ + source?: Source; +} + +/** The creatable-type metadata for a source's stored type, falling back to folder. */ +function typeFor(type: string | undefined): CreatableSourceType { + return CREATABLE_SOURCE_TYPES.find((t) => t.type === type) ?? DEFAULT_TYPE; +} + +/** Source options coerced to strings for the form, defaulted from the type's fields. */ +function optionsFor( + type: CreatableSourceType, + options: Record | undefined, +): Record { + const out = defaultOptions(type); + for (const [key, value] of Object.entries(options ?? {})) { + out[key] = value == null ? "" : String(value); + } + return out; } /** - * Guided shell for connecting a new source. The final step is a demo stub that - * closes without provisioning — wiring it to the backend creates the source. + * Guided flow for connecting a source (choose type -> configure -> review) or + * editing an existing one (configure -> review, type fixed). On submit a blank + * id creates and a set id updates, matching the backend's POST contract. */ -export function ConnectWizard({ open, onClose }: ConnectWizardProps) { +export function ConnectWizard({ + open, + onClose, + onCreated, + source, +}: ConnectWizardProps) { const { t } = useTranslation(); - const [step, setStep] = useState(0); - const [type, setType] = useState("agent"); + const isEdit = source !== undefined; + const steps = isEdit ? EDIT_STEPS : CREATE_STEPS; - const wizardSteps = [ - t("sources.wizard.steps.chooseType"), - t("sources.wizard.steps.configure"), - t("sources.wizard.steps.review"), - ]; + const [stepIndex, setStepIndex] = useState(0); + const [type, setType] = useState(() => + typeFor(source?.type), + ); + const [name, setName] = useState(source?.name ?? ""); + const [options, setOptions] = useState>(() => + optionsFor(typeFor(source?.type), source?.options), + ); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); - function close() { - onClose(); - // Reset for the next open, after the close transition has finished. - setTimeout(() => { - setStep(0); - setType("agent"); - }, 200); + // Re-seed the form whenever the wizard opens (or its target source changes) so + // editing prefills the current config and a reopened create starts clean. + useEffect(() => { + if (!open) return; + const ct = typeFor(source?.type); + setStepIndex(0); + setType(ct); + setName(source?.name ?? ""); + setOptions(optionsFor(ct, source?.options)); + setSubmitting(false); + setError(null); + }, [open, source]); + + const stepId = steps[stepIndex]; + const isLast = stepIndex === steps.length - 1; + + function chooseType(next: CreatableSourceType) { + setType(next); + setOptions(defaultOptions(next)); } - const isLast = step === WIZARD_STEP_COUNT - 1; + const requiredFilled = type.fields.every( + (f) => !f.required || (options[f.key] ?? "").trim() !== "", + ); + const canContinue = + stepId === "configure" ? name.trim() !== "" && requiredFilled : true; - function advance() { - if (isLast) { - // TODO(backend): POST /v1/sources { type, pipeline, region } — provision - // the source, then close on success. - close(); - } else { - setStep((s) => s + 1); + async function advance() { + if (!isLast) { + setStepIndex((i) => i + 1); + return; + } + setSubmitting(true); + setError(null); + try { + const fields = { + name: name.trim(), + type: type.type, + options, + enabled: source?.enabled ?? true, + }; + await createSource(isEdit ? { ...fields, id: source.id } : fields); + onCreated(); + onClose(); + } catch (e) { + setError(errorMessage(e)); + } finally { + setSubmitting(false); } } + const stepLabels: Record = { + type: t("sources.wizard.steps.chooseType"), + configure: t("sources.wizard.steps.configure"), + review: t("sources.wizard.steps.review"), + }; + return ( } >
    - {wizardSteps.map((label, i) => ( + {steps.map((id, i) => (
  1. - {i < step ? "✓" : i + 1} + {i < stepIndex ? "✓" : i + 1} - {label} + {stepLabels[id]}
  2. ))}
- {step === 0 && ( + {stepId === "type" && (
- {(Object.keys(SOURCE_TYPE_META) as Source["type"][]).map((t) => { - const meta = SOURCE_TYPE_META[t]; - return ( - - ); - })} + {CREATABLE_SOURCE_TYPES.map((ct) => ( + + ))}
)} - {step === 1 && ( + {stepId === "configure" && (
-

- {t("sources.wizard.configureLead.before")}{" "} - {SOURCE_TYPE_META[type].label} - {t("sources.wizard.configureLead.after")} -

- -

- {t("sources.wizard.configureNote")} -

+ + setName(e.target.value)} + /> + + {type.fields.map((field) => ( + + {field.control === "select" ? ( + + setOptions((o) => ({ ...o, [field.key]: e.target.value })) + } + /> + )} + + ))}
)} - {step === 2 && ( + {stepId === "review" && (
-

- {t("sources.wizard.reviewLead.before")}{" "} - {SOURCE_TYPE_META[type].label} - {t("sources.wizard.reviewLead.after")} -

+ - - - + {type.fields.map((field) => ( + + ))}
+ {error && }
)}
diff --git a/frontend/portal/src/components/sources/KpiStrip.stories.tsx b/frontend/portal/src/components/sources/KpiStrip.stories.tsx index 36c290b50b..c2233dcb47 100644 --- a/frontend/portal/src/components/sources/KpiStrip.stories.tsx +++ b/frontend/portal/src/components/sources/KpiStrip.stories.tsx @@ -1,7 +1,16 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { buildSourcesResponse } from "@portal/mocks/sources"; +import type { SourcesResponse } from "@portal/api/sources"; import { KpiStrip } from "@portal/components/sources/KpiStrip"; +const RESPONSE: SourcesResponse = { + kpis: [ + { value: 4, description: "connections" }, + { value: 2, description: "referenced by a policy" }, + { value: 2, description: "unused" }, + ], + sources: [], +}; + const meta: Meta = { title: "Portal/Sources/KpiStrip", component: KpiStrip, @@ -10,20 +19,11 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Pro: Story = { - args: { data: buildSourcesResponse("pro"), loading: false }, -}; - -export const Enterprise: Story = { - args: { data: buildSourcesResponse("enterprise"), loading: false }, +export const Ready: Story = { + args: { data: RESPONSE, loading: false }, }; /** Loading collapses every card to the placeholder dash. */ export const Loading: Story = { args: { data: null, loading: true }, }; - -/** Free tier reports zeros and a "connect a source" prompt. */ -export const Free: Story = { - args: { data: buildSourcesResponse("free"), loading: false }, -}; diff --git a/frontend/portal/src/components/sources/KpiStrip.tsx b/frontend/portal/src/components/sources/KpiStrip.tsx index 12f242bcad..8bdc511b57 100644 --- a/frontend/portal/src/components/sources/KpiStrip.tsx +++ b/frontend/portal/src/components/sources/KpiStrip.tsx @@ -3,15 +3,15 @@ import { MetricCard, MetricStrip } from "@shared/components"; import type { SourcesResponse } from "@portal/api/sources"; /** - * KPI labels are product copy — they describe what each metric IS, not its - * current value. They stay client-side so the strip's structure is stable - * across loading / empty / ready states; only values + deltas flow from the API. + * KPI labels are product copy: they describe what each metric IS, not its + * current 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 SourceOverviewService.buildKpis: total, in-use, unused. */ const KPI_LABEL_KEYS = [ - "sources.kpi.agentsActive", - "sources.kpi.scenarios", - "sources.kpi.evalPassRate", - "sources.kpi.docs24h", + "sources.kpi.total", + "sources.kpi.inUse", + "sources.kpi.unused", ] as const; interface KpiStripProps { @@ -30,8 +30,6 @@ export function KpiStrip({ data, loading }: KpiStripProps) { key={labelKey} label={t(labelKey)} value={k?.value ?? "—"} - delta={k?.delta} - deltaDirection={k?.deltaDirection} description={k?.description} /> ); diff --git a/frontend/portal/src/components/sources/SourceDetailCard.stories.tsx b/frontend/portal/src/components/sources/SourceDetailCard.stories.tsx index 83f5171ecc..0f9ddee521 100644 --- a/frontend/portal/src/components/sources/SourceDetailCard.stories.tsx +++ b/frontend/portal/src/components/sources/SourceDetailCard.stories.tsx @@ -1,17 +1,45 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { Source } from "@portal/api/sources"; -import { sourcesFor } from "@portal/mocks/sources"; +import type { SourceView } from "@portal/api/sources"; import { SourceDetailCard } from "@portal/components/sources/SourceDetailCard"; -const ENTERPRISE = sourcesFor("enterprise"); -const byType = (type: Source["type"]) => - ENTERPRISE.find((s) => s.type === type)!; +const IN_USE: SourceView = { + id: "src-claims", + name: "Claims intake", + type: "folder", + status: "active", + referenceCount: 2, + referencingPolicies: [ + { id: "a", name: "Security Policy" }, + { id: "b", name: "Redaction Policy" }, + ], + config: [ + { label: "Directory", value: "/data/claims-intake" }, + { label: "Mode", value: "consume" }, + ], + docsTotal: null, +}; + +const ORPHANED: SourceView = { + id: "src-archive", + name: "Archive reprocess", + type: "folder", + status: "unused", + referenceCount: 0, + referencingPolicies: [], + config: [{ label: "Directory", value: "/data/archive" }], + docsTotal: null, +}; const meta: Meta = { title: "Portal/Sources/SourceDetailCard", component: SourceDetailCard, parameters: { layout: "padded" }, - args: { onClose: () => {} }, + args: { + onClose: () => {}, + onEdit: () => {}, + onTogglePause: () => {}, + onDelete: () => {}, + }, decorators: [ (S) => (
@@ -23,6 +51,5 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Agent: Story = { args: { source: byType("agent") } }; -export const Webhook: Story = { args: { source: byType("webhook") } }; -export const Connector: Story = { args: { source: byType("connector") } }; +export const InUse: Story = { args: { source: IN_USE } }; +export const Orphaned: Story = { args: { source: ORPHANED } }; diff --git a/frontend/portal/src/components/sources/SourceDetailCard.tsx b/frontend/portal/src/components/sources/SourceDetailCard.tsx index e60f502760..6169116c0f 100644 --- a/frontend/portal/src/components/sources/SourceDetailCard.tsx +++ b/frontend/portal/src/components/sources/SourceDetailCard.tsx @@ -1,17 +1,32 @@ import { useTranslation } from "react-i18next"; -import { type Source, SOURCE_TYPE_META } from "@portal/api/sources"; +import { Button } from "@shared/components"; +import type { SourceView } from "@portal/api/sources"; import { SourceDetailPanel } from "@portal/components/sources/SourceDetailPanel"; +import { sourceTypeMeta } from "@portal/components/sources/sourceTypes"; import "@portal/views/Sources.css"; interface SourceDetailCardProps { - source: Source; + source: SourceView; onClose: () => void; + onEdit: (source: SourceView) => void; + onTogglePause: (source: SourceView) => void; + onDelete: (source: SourceView) => void; + /** Disables the actions while a mutation is in flight. */ + busy?: boolean; } -/** Expanded type-specific detail for the selected table row. */ -export function SourceDetailCard({ source, onClose }: SourceDetailCardProps) { +/** Expanded detail for the selected source row, with edit/pause/delete actions. */ +export function SourceDetailCard({ + source, + onClose, + onEdit, + onTogglePause, + onDelete, + busy = false, +}: SourceDetailCardProps) { const { t } = useTranslation(); - const meta = SOURCE_TYPE_META[source.type]; + const meta = sourceTypeMeta(source.type); + const paused = source.status === "disabled"; return (
@@ -24,9 +39,9 @@ export function SourceDetailCard({ source, onClose }: SourceDetailCardProps) {

{source.name}

- {t("sources.detail.ownedBy", { - type: meta.label, - owner: source.owner, + {t("sources.detail.subtitle", { + type: t(meta.labelKey), + status: t(`sources.status.${source.status}`), })}
@@ -39,7 +54,33 @@ export function SourceDetailCard({ source, onClose }: SourceDetailCardProps) { ×
+ + +
+ + + +
); } diff --git a/frontend/portal/src/components/sources/SourceDetailPanel.stories.tsx b/frontend/portal/src/components/sources/SourceDetailPanel.stories.tsx index 2581771ea3..79e8fe9c86 100644 --- a/frontend/portal/src/components/sources/SourceDetailPanel.stories.tsx +++ b/frontend/portal/src/components/sources/SourceDetailPanel.stories.tsx @@ -1,11 +1,34 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { Source } from "@portal/api/sources"; -import { sourcesFor } from "@portal/mocks/sources"; +import type { SourceView } from "@portal/api/sources"; import { SourceDetailPanel } from "@portal/components/sources/SourceDetailPanel"; -const ENTERPRISE = sourcesFor("enterprise"); -const byType = (type: Source["type"]) => - ENTERPRISE.find((s) => s.type === type)!; +const IN_USE: SourceView = { + id: "src-claims", + name: "Claims intake", + type: "folder", + status: "active", + referenceCount: 2, + referencingPolicies: [ + { id: "a", name: "Security Policy" }, + { id: "b", name: "Redaction Policy" }, + ], + config: [ + { label: "Directory", value: "/data/claims-intake" }, + { label: "Mode", value: "consume" }, + ], + docsTotal: null, +}; + +const ORPHANED: SourceView = { + id: "src-archive", + name: "Archive reprocess", + type: "folder", + status: "unused", + referenceCount: 0, + referencingPolicies: [], + config: [{ label: "Directory", value: "/data/archive" }], + docsTotal: null, +}; const meta: Meta = { title: "Portal/Sources/SourceDetailPanel", @@ -22,8 +45,6 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Agent: Story = { args: { source: byType("agent") } }; -export const ApiClient: Story = { args: { source: byType("apiclient") } }; -export const Webhook: Story = { args: { source: byType("webhook") } }; -/** "basic" covers editor, connector, email, desktop and batch sources. */ -export const Basic: Story = { args: { source: byType("connector") } }; +export const InUse: Story = { args: { source: IN_USE } }; +/** A source no policy references is called out as safe to delete. */ +export const Orphaned: Story = { args: { source: ORPHANED } }; diff --git a/frontend/portal/src/components/sources/SourceDetailPanel.tsx b/frontend/portal/src/components/sources/SourceDetailPanel.tsx index 1938eb609b..8fa435bb6c 100644 --- a/frontend/portal/src/components/sources/SourceDetailPanel.tsx +++ b/frontend/portal/src/components/sources/SourceDetailPanel.tsx @@ -1,34 +1,46 @@ -import { StatTile } from "@shared/components"; -import type { BasicDetail, Source } from "@portal/api/sources"; -import { AgentPanel } from "@portal/components/sources/AgentPanel"; -import { ApiClientPanel } from "@portal/components/sources/ApiClientPanel"; -import { WebhookPanel } from "@portal/components/sources/WebhookPanel"; +import { useTranslation } from "react-i18next"; +import { Chip, StatTile } from "@shared/components"; +import type { SourceView } from "@portal/api/sources"; import "@portal/views/Sources.css"; -/** Generic key/value grid for the simpler source types (editor, connector, …). */ -function BasicPanel({ rows }: { rows: BasicDetail["rows"] }) { +/** + * Expanded detail for a source row: its config (key/value) plus which policies + * reference it. A 0-reference source is called out as safe to delete. + */ +export function SourceDetailPanel({ source }: { source: SourceView }) { + const { t } = useTranslation(); return (
-
- {rows.map((r) => ( - - ))} + {source.config.length > 0 && ( +
+ {source.config.map((row) => ( + + ))} +
+ )} + +
+ + {t("sources.detail.usedBy")} + + {source.referencingPolicies.length === 0 ? ( +

+ {t("sources.detail.notReferenced")} +

+ ) : ( +
+ {source.referencingPolicies.map((policy) => ( + + {policy.name} + + ))} +
+ )}
+ +

+ {t("sources.detail.docsUntracked")} +

); } - -/** Renders the detail payload for a source, dispatched on its discriminant. */ -export function SourceDetailPanel({ source }: { source: Source }) { - const { detail } = source; - switch (detail.kind) { - case "agent": - return ; - case "apiclient": - return ; - case "webhook": - return ; - case "basic": - return ; - } -} diff --git a/frontend/portal/src/components/sources/SourcesTable.stories.tsx b/frontend/portal/src/components/sources/SourcesTable.stories.tsx index 7d7ea6fdc8..30f1e5bc5f 100644 --- a/frontend/portal/src/components/sources/SourcesTable.stories.tsx +++ b/frontend/portal/src/components/sources/SourcesTable.stories.tsx @@ -1,14 +1,51 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { sourcesFor } from "@portal/mocks/sources"; +import type { SourceView } from "@portal/api/sources"; import { SourcesTable } from "@portal/components/sources/SourcesTable"; -const PRO = sourcesFor("pro"); +const SOURCES: SourceView[] = [ + { + id: "src-claims", + name: "Claims intake", + type: "folder", + status: "active", + referenceCount: 2, + referencingPolicies: [ + { id: "a", name: "Security Policy" }, + { id: "b", name: "Redaction Policy" }, + ], + config: [ + { label: "Directory", value: "/data/claims-intake" }, + { label: "Mode", value: "consume" }, + ], + docsTotal: null, + }, + { + id: "src-archive", + name: "Archive reprocess", + type: "folder", + status: "unused", + referenceCount: 0, + referencingPolicies: [], + config: [{ label: "Directory", value: "/data/archive" }], + docsTotal: null, + }, + { + id: "src-legacy", + name: "Legacy share (paused)", + type: "folder", + status: "disabled", + referenceCount: 0, + referencingPolicies: [], + config: [{ label: "Directory", value: "/mnt/legacy" }], + docsTotal: null, + }, +]; const meta: Meta = { title: "Portal/Sources/SourcesTable", component: SourcesTable, parameters: { layout: "padded" }, - args: { sources: PRO, expandedId: null, onRowClick: () => {} }, + args: { sources: SOURCES, expandedId: null, onRowClick: () => {} }, }; export default meta; type Story = StoryObj; @@ -17,9 +54,5 @@ export const Default: Story = {}; /** A row with an open detail panel rotates its caret. */ export const RowExpanded: Story = { - args: { expandedId: PRO[1].id }, -}; - -export const Enterprise: Story = { - args: { sources: sourcesFor("enterprise") }, + args: { expandedId: SOURCES[0].id }, }; diff --git a/frontend/portal/src/components/sources/SourcesTable.tsx b/frontend/portal/src/components/sources/SourcesTable.tsx index a0cb15f520..0f6c23589c 100644 --- a/frontend/portal/src/components/sources/SourcesTable.tsx +++ b/frontend/portal/src/components/sources/SourcesTable.tsx @@ -1,18 +1,27 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Chip, StatusBadge, Table, type TableColumn } from "@shared/components"; import { - type Source, - SOURCE_STATUS_TONE, - SOURCE_TYPE_META, -} from "@portal/api/sources"; + Chip, + StatusBadge, + type StatusTone, + Table, + type TableColumn, +} from "@shared/components"; +import type { SourceStatus, SourceView } from "@portal/api/sources"; +import { sourceTypeMeta } from "@portal/components/sources/sourceTypes"; import "@portal/views/Sources.css"; +const STATUS_TONE: Record = { + active: "success", + unused: "neutral", + disabled: "warning", +}; + interface SourcesTableProps { - sources: Source[]; + sources: SourceView[]; /** Id of the row whose detail panel is open, drives the caret state. */ expandedId: string | null; - onRowClick: (source: Source) => void; + onRowClick: (source: SourceView) => void; } export function SourcesTable({ @@ -21,13 +30,13 @@ export function SourcesTable({ onRowClick, }: SourcesTableProps) { const { t } = useTranslation(); - const columns = useMemo[]>( + const columns = useMemo[]>( () => [ { key: "name", header: t("sources.table.source"), render: (s) => { - const meta = SOURCE_TYPE_META[s.type]; + const meta = sourceTypeMeta(s.type); return (
{s.name} - {meta.label} + {t(meta.labelKey)}
@@ -51,38 +60,28 @@ export function SourcesTable({ header: t("sources.table.status"), render: (s) => ( - {s.status} + {t(`sources.status.${s.status}`)} ), }, { - key: "docs24h", - header: t("sources.table.docs24h"), + key: "referenceCount", + header: t("sources.table.usedBy"), align: "right", - render: (s) => s.docs24h.toLocaleString(), - }, - { - key: "docs30d", - header: t("sources.table.docs30d"), - align: "right", - render: (s) => s.docs30d.toLocaleString(), - }, - { - key: "lastEvent", - header: t("sources.table.lastEvent"), render: (s) => ( - {s.lastEvent} + + {s.referenceCount} + ), }, - { - key: "owner", - header: t("sources.table.owner"), - render: (s) => {s.owner}, - }, { key: "expand", header: "", @@ -104,7 +103,7 @@ export function SourcesTable({ ); return ( - + className="portal-sources__table" columns={columns} rows={sources} diff --git a/frontend/portal/src/components/sources/WebhookPanel.stories.tsx b/frontend/portal/src/components/sources/WebhookPanel.stories.tsx deleted file mode 100644 index 673fc08a92..0000000000 --- a/frontend/portal/src/components/sources/WebhookPanel.stories.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { WebhookDetail } from "@portal/api/sources"; -import { WebhookPanel } from "@portal/components/sources/WebhookPanel"; - -const meta: Meta = { - title: "Portal/Sources/WebhookPanel", - component: WebhookPanel, - parameters: { layout: "padded" }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -const healthy: WebhookDetail = { - kind: "webhook", - url: "https://hooks.acme.com/stirling/ingest", - authType: "HMAC-SHA256", - successRate: 0.991, - retries24h: 3, - recentDeliveries: [ - { event: "document.processed", status: 200, time: "9m ago" }, - { event: "pipeline.completed", status: 200, time: "22m ago" }, - { event: "document.processed", status: 503, time: "1h ago" }, - ], -}; - -export const Healthy: Story = { args: { d: healthy } }; - -/** Below the 95% floor: success badge goes danger, retries spike, 5xx deliveries. */ -export const Failing: Story = { - args: { - d: { - ...healthy, - url: "https://erp.acme.com/inbound/stirling", - authType: "Basic", - successRate: 0.72, - retries24h: 58, - recentDeliveries: [ - { event: "document.processed", status: 502, time: "2m ago" }, - { event: "document.processed", status: 502, time: "5m ago" }, - { event: "pipeline.completed", status: 200, time: "9m ago" }, - ], - }, - }, -}; diff --git a/frontend/portal/src/components/sources/WebhookPanel.tsx b/frontend/portal/src/components/sources/WebhookPanel.tsx deleted file mode 100644 index 0edd58fd20..0000000000 --- a/frontend/portal/src/components/sources/WebhookPanel.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Button, StatTile, StatusBadge } from "@shared/components"; -import type { WebhookDetail } from "@portal/api/sources"; -import { pct } from "@portal/components/sources/format"; -import "@portal/views/Sources.css"; - -export function WebhookPanel({ d }: { d: WebhookDetail }) { - const { t } = useTranslation(); - const rateTone = - d.successRate >= 0.99 - ? "success" - : d.successRate >= 0.95 - ? "warning" - : "danger"; - return ( -
-
- {d.url}} - /> - - - {pct(d.successRate)} - - } - /> - -
- -
- - {t("sources.webhook.recentDeliveries")} - -
- {d.recentDeliveries.map((r, i) => ( -
- - {r.status} - - {r.event} - {r.time} -
- ))} -
-
- - {/* TODO(backend): wire to POST /v1/sources/{id}/test-event and - GET /v1/sources/{id}/signing-secret — currently inert demo controls. */} -
- - -
-
- ); -} diff --git a/frontend/portal/src/components/sources/format.ts b/frontend/portal/src/components/sources/format.ts deleted file mode 100644 index 5ddbaaaaff..0000000000 --- a/frontend/portal/src/components/sources/format.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** Format a 0..1 ratio as a one-decimal percentage, e.g. 0.962 → "96.2%". */ -export function pct(n: number): string { - return `${(n * 100).toFixed(1)}%`; -} diff --git a/frontend/portal/src/components/sources/sourceTypes.ts b/frontend/portal/src/components/sources/sourceTypes.ts new file mode 100644 index 0000000000..dd4b61470b --- /dev/null +++ b/frontend/portal/src/components/sources/sourceTypes.ts @@ -0,0 +1,95 @@ +import type { ChipTone } from "@shared/components"; + +/** + * Per-type presentation + create-form metadata. User-facing copy is stored as + * i18n keys (resolved by the rendering component via t()), not literals, so the + * table chip, type picker, and configure step stay translatable. The structure + * lives client-side so it stays stable regardless of which connections exist. + * The backend's source `type` string keys into here; unknown types fall back + * gracefully. + */ + +export interface SourceTypeMeta { + labelKey: string; + icon: string; + tone: ChipTone; +} + +const SOURCE_TYPE_META: Record = { + folder: { labelKey: "sources.types.folder.label", icon: "⛁", tone: "blue" }, +}; + +const UNKNOWN_TYPE_META: SourceTypeMeta = { + labelKey: "sources.types.unknown.label", + icon: "◇", + tone: "neutral", +}; + +export function sourceTypeMeta(type: string): SourceTypeMeta { + return SOURCE_TYPE_META[type] ?? UNKNOWN_TYPE_META; +} + +/** One configurable field for a creatable source type. */ +export interface SourceFieldDef { + key: string; + labelKey: string; + control: "text" | "select"; + required?: boolean; + placeholderKey?: string; + helperTextKey?: string; + options?: { value: string; labelKey: string }[]; + defaultValue?: string; +} + +/** A source type the wizard can create, with the fields its config needs. */ +export interface CreatableSourceType { + type: string; + labelKey: string; + descriptionKey: string; + fields: SourceFieldDef[]; +} + +export const CREATABLE_SOURCE_TYPES: CreatableSourceType[] = [ + { + type: "folder", + labelKey: "sources.types.folder.label", + descriptionKey: "sources.types.folder.description", + fields: [ + { + key: "directory", + labelKey: "sources.types.folder.fields.directory.label", + control: "text", + required: true, + placeholderKey: "sources.types.folder.fields.directory.placeholder", + helperTextKey: "sources.types.folder.fields.directory.helperText", + }, + { + key: "mode", + labelKey: "sources.types.folder.fields.mode.label", + control: "select", + defaultValue: "consume", + options: [ + { + value: "consume", + labelKey: "sources.types.folder.fields.mode.options.consume", + }, + { + value: "snapshot", + labelKey: "sources.types.folder.fields.mode.options.snapshot", + }, + ], + }, + ], + }, +]; + +/** Default option values for a type's create form. */ +export function defaultOptions( + type: CreatableSourceType, +): Record { + const out: Record = {}; + for (const field of type.fields) { + out[field.key] = field.defaultValue ?? ""; + } + return out; +} diff --git a/frontend/portal/src/mocks/handlers/sources.ts b/frontend/portal/src/mocks/handlers/sources.ts index d44322e992..90418caf56 100644 --- a/frontend/portal/src/mocks/handlers/sources.ts +++ b/frontend/portal/src/mocks/handlers/sources.ts @@ -1,12 +1,182 @@ import { http, HttpResponse, delay } from "msw"; -import type { Tier } from "@portal/contexts/TierContext"; -import { buildSourcesResponse } from "@portal/mocks/sources"; +import type { + Source, + SourceKpi, + SourcePolicyRef, + SourceStatus, + SourceView, + SourcesResponse, +} from "@portal/api/sources"; + +/** + * Stateful mock for the Sources surface so the portal works fully offline with + * mocks on. Mirrors the real backend shape (`/api/v1/sources`, SourceController + + * SourceOverviewService): create/delete mutate an in-memory store, and delete of + * a still-referenced source returns 409. With mocks OFF these calls fall through + * to the real backend instead, like any other `/api/v1/...` surface. + */ + +interface StoredSource extends Source { + id: string; +} + +function seedSources(): StoredSource[] { + return [ + { + id: "src-claims", + name: "Claims intake", + type: "folder", + options: { directory: "/data/claims-intake", mode: "consume" }, + enabled: true, + owner: "you@acme.com", + }, + { + id: "src-contracts", + name: "Contracts drop", + type: "folder", + options: { directory: "/data/contracts", mode: "snapshot" }, + enabled: true, + owner: "legal-ops@acme.com", + }, + { + id: "src-archive", + name: "Archive reprocess", + type: "folder", + options: { directory: "/data/archive", mode: "consume" }, + enabled: true, + owner: "data-eng@acme.com", + }, + { + id: "src-legacy", + name: "Legacy share (paused)", + type: "folder", + options: { directory: "/mnt/legacy" }, + enabled: false, + owner: "data-eng@acme.com", + }, + ]; +} + +/** Which seeded policies reference each seeded source (drives reference counts). */ +const references: Record = { + "src-claims": [ + { id: "pol_security", name: "Security Policy" }, + { id: "pol_redaction", name: "Redaction Policy" }, + ], + "src-contracts": [{ id: "pol_contract", name: "Contract Review" }], +}; + +let store: StoredSource[] = seedSources(); + +let idCounter = 0; +function nextId(): string { + idCounter += 1; + return `src_${Date.now().toString(36)}_${idCounter}`; +} + +function refsFor(id: string): SourcePolicyRef[] { + return references[id] ?? []; +} + +function configRows(options: Record) { + return Object.entries(options).map(([key, value]) => ({ + label: key.charAt(0).toUpperCase() + key.slice(1), + value: String(value), + })); +} + +function deriveStatus( + source: StoredSource, + referenceCount: number, +): SourceStatus { + if (!source.enabled) return "disabled"; + return referenceCount === 0 ? "unused" : "active"; +} + +function toSourceView( + source: StoredSource, + refs: SourcePolicyRef[], +): SourceView { + return { + id: source.id, + name: source.name, + type: source.type, + status: deriveStatus(source, refs.length), + referenceCount: refs.length, + referencingPolicies: refs, + config: configRows(source.options), + docsTotal: null, + }; +} + +function buildKpis(views: SourceView[]): SourceKpi[] { + const total = views.length; + const inUse = views.filter((v) => v.referenceCount > 0).length; + return [ + { value: total, description: "connections" }, + { value: inUse, description: "referenced by a policy" }, + { value: total - inUse, description: "unused" }, + ]; +} + +function buildOverview(): SourcesResponse { + const views = store + .map((s) => toSourceView(s, refsFor(s.id))) + .sort( + (a, b) => + b.referenceCount - a.referenceCount || a.name.localeCompare(b.name), + ); + return { kpis: buildKpis(views), sources: views }; +} export const sourcesHandlers = [ - http.get("/v1/sources", async ({ request }) => { + http.get("/api/v1/sources", async () => { await delay(120); - const url = new URL(request.url); - const tier = (url.searchParams.get("tier") ?? "pro") as Tier; - return HttpResponse.json(buildSourcesResponse(tier)); + return HttpResponse.json(buildOverview()); + }), + + http.get("/api/v1/sources/:id", async ({ params }) => { + await delay(120); + const source = store.find((s) => s.id === params.id); + if (!source) return new HttpResponse(null, { status: 404 }); + return HttpResponse.json(source); + }), + + http.post("/api/v1/sources", async ({ request }) => { + await delay(120); + const incoming = (await request.json()) as Source; + const existing = incoming.id + ? store.find((s) => s.id === incoming.id) + : undefined; + const id = existing?.id ?? nextId(); + const saved: StoredSource = { + ...incoming, + id, + owner: existing?.owner ?? "you@acme.com", + }; + store = existing + ? store.map((s) => (s.id === id ? saved : s)) + : [...store, saved]; + return HttpResponse.json(saved); + }), + + http.delete("/api/v1/sources/:id", async ({ params }) => { + await delay(120); + const id = String(params.id); + const source = store.find((s) => s.id === id); + if (!source) return new HttpResponse(null, { status: 404 }); + const refs = refsFor(id); + if (refs.length > 0) { + return HttpResponse.json( + { + detail: `Source is referenced by ${refs.length} policy(ies): ${refs + .map((r) => r.name) + .join(", ")}`, + }, + { status: 409 }, + ); + } + store = store.filter((s) => s.id !== id); + return new HttpResponse(null, { status: 204 }); }), ]; diff --git a/frontend/portal/src/mocks/sources.ts b/frontend/portal/src/mocks/sources.ts deleted file mode 100644 index d881043a33..0000000000 --- a/frontend/portal/src/mocks/sources.ts +++ /dev/null @@ -1,545 +0,0 @@ -/** - * Sources & Agents fixtures and the types api/sources.ts shares with them. - * - * A "source" is anything that feeds documents into Stirling — an interactive - * editor session, an autonomous agent, an API client, a webhook, a storage - * connector, an email inbox, a desktop app, or a batch job. Each carries a - * type-specific detail payload surfaced when its table row is expanded. - * - * api/sources.ts imports the types; the MSW handlers serve the fixture data - * over the intercepted httpJson() calls. Components never reach into this - * module directly. Once a real backend exists the handlers stop being - * registered and these fixtures can be deleted (or kept as test seeds). - */ - -import type { Tier } from "@portal/contexts/TierContext"; - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Source types */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export type SourceType = - | "editor" - | "agent" - | "apiclient" - | "webhook" - | "connector" - | "email" - | "desktop" - | "batch"; - -export type SourceStatus = "active" | "idle" | "degraded" | "paused" | "error"; - -/** Type-specific detail payloads, discriminated by `kind`. */ - -export interface AgentDetail { - kind: "agent"; - model: string; - /** Calls over the trailing 24h. */ - calls24h: number; - errorRate: number; - /** Mean output confidence 0..1. */ - confidence: number; - escalations24h: number; - /** Pipelines this agent is allowed to invoke. */ - assignedPipelines: string[]; - scopes: string[]; -} - -export interface ApiClientDetail { - kind: "apiclient"; - /** Pre-masked for display — never carries the real secret. */ - maskedKey: string; - rateLimit: string; - /** Requests used against the current rate-limit window. */ - rateUsedPct: number; - endpoints: { method: string; path: string; calls24h: number }[]; - createdBy: string; - lastRotated: string; -} - -export interface WebhookDetail { - kind: "webhook"; - url: string; - authType: "HMAC-SHA256" | "Bearer token" | "Basic" | "None"; - successRate: number; - /** Recent deliveries, newest first. */ - recentDeliveries: { event: string; status: number; time: string }[]; - retries24h: number; -} - -/** Generic key/value detail for the simpler source types. */ -export interface BasicDetail { - kind: "basic"; - rows: { label: string; value: string }[]; -} - -export type SourceDetail = - | AgentDetail - | ApiClientDetail - | WebhookDetail - | BasicDetail; - -export interface Source { - id: string; - name: string; - type: SourceType; - status: SourceStatus; - docs24h: number; - docs30d: number; - /** Relative-time string, e.g. "2m ago". */ - lastEvent: string; - owner: string; - detail: SourceDetail; -} - -export interface SourcesKpi { - value: string | number; - delta?: number; - deltaDirection?: "up" | "down" | "flat"; - description?: string; -} - -export interface SourcesResponse { - kpis: SourcesKpi[]; - sources: Source[]; -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Per-type presentation metadata (icon + chip tone + label) */ -/* Lives client-side — it's product copy, not data. Re-exported for the */ -/* view via api/sources.ts. */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export interface SourceTypeMeta { - label: string; - icon: string; - tone: "neutral" | "blue" | "purple" | "green" | "amber" | "red"; -} - -export const SOURCE_TYPE_META: Record = { - editor: { label: "Editor", icon: "✎", tone: "neutral" }, - agent: { label: "Agent", icon: "◆", tone: "purple" }, - apiclient: { label: "API client", icon: "⌘", tone: "blue" }, - webhook: { label: "Webhook", icon: "⇄", tone: "green" }, - connector: { label: "Connector", icon: "⛁", tone: "amber" }, - email: { label: "Email inbox", icon: "✉", tone: "blue" }, - desktop: { label: "Desktop", icon: "▣", tone: "neutral" }, - batch: { label: "Batch job", icon: "≡", tone: "amber" }, -}; - -export const SOURCE_STATUS_TONE: Record< - SourceStatus, - "success" | "warning" | "danger" | "neutral" | "info" -> = { - active: "success", - idle: "neutral", - degraded: "warning", - paused: "info", - error: "danger", -}; - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Fixture builders */ -/* ──────────────────────────────────────────────────────────────────────── */ - -function agentDetail(over: Partial): AgentDetail { - return { - kind: "agent", - model: "claude-sonnet-4.5", - calls24h: 0, - errorRate: 0, - confidence: 0.95, - escalations24h: 0, - assignedPipelines: [], - scopes: [], - ...over, - }; -} - -const PRO_SOURCES: Source[] = [ - { - id: "src-editor-1", - name: "Web Editor — workspace", - type: "editor", - status: "active", - docs24h: 38, - docs30d: 642, - lastEvent: "4m ago", - owner: "you@acme.com", - detail: { - kind: "basic", - rows: [ - { label: "Session", value: "Browser · Chrome 126" }, - { label: "Active editors", value: "3 this week" }, - { label: "Default pipeline", value: "Redact & Flatten" }, - { label: "Region", value: "us-east-1" }, - ], - }, - }, - { - id: "src-agent-1", - name: "Invoice Extractor", - type: "agent", - status: "active", - docs24h: 1287, - docs30d: 31840, - lastEvent: "32s ago", - owner: "platform@acme.com", - detail: agentDetail({ - model: "claude-sonnet-4.5", - calls24h: 1342, - errorRate: 0.004, - confidence: 0.962, - escalations24h: 11, - assignedPipelines: ["Invoice v3", "AP Routing"], - scopes: ["documents:read", "pipelines:invoke", "extract:write"], - }), - }, - { - id: "src-agent-2", - name: "Contract Router", - type: "agent", - status: "degraded", - docs24h: 412, - docs30d: 9870, - lastEvent: "6m ago", - owner: "legal-ops@acme.com", - // Degraded: error rate over the 5% alarm threshold and confidence below the - // green band, so the panel renders danger/amber tones. - detail: agentDetail({ - model: "claude-opus-4.1", - calls24h: 455, - errorRate: 0.071, - confidence: 0.883, - escalations24h: 34, - assignedPipelines: ["Contract Review", "DPA Classifier"], - scopes: ["documents:read", "pipelines:invoke", "review:escalate"], - }), - }, - { - id: "src-agent-3", - name: "KYC Processor", - type: "agent", - status: "active", - docs24h: 768, - docs30d: 18420, - lastEvent: "1m ago", - owner: "risk@acme.com", - detail: agentDetail({ - model: "claude-sonnet-4.5", - calls24h: 802, - errorRate: 0.012, - confidence: 0.941, - escalations24h: 7, - assignedPipelines: ["KYC Onboarding"], - scopes: ["documents:read", "pipelines:invoke", "pii:read"], - }), - }, - { - id: "src-api-1", - name: "Acme Production", - type: "apiclient", - status: "active", - docs24h: 2940, - docs30d: 71200, - lastEvent: "11s ago", - owner: "platform@acme.com", - detail: { - kind: "apiclient", - maskedKey: "sk_live_••••••••••••4f9a", - rateLimit: "600 req/min", - rateUsedPct: 0.42, - endpoints: [ - { method: "POST", path: "/v1/extract", calls24h: 1820 }, - { method: "POST", path: "/v1/redact", calls24h: 740 }, - { method: "GET", path: "/v1/documents/{id}", calls24h: 380 }, - ], - createdBy: "you@acme.com", - lastRotated: "23 days ago", - }, - }, - { - id: "src-webhook-1", - name: "Slack delivery hook", - type: "webhook", - status: "active", - docs24h: 96, - docs30d: 2310, - lastEvent: "9m ago", - owner: "platform@acme.com", - detail: { - kind: "webhook", - url: "https://hooks.acme.com/stirling/ingest", - authType: "HMAC-SHA256", - successRate: 0.991, - retries24h: 3, - recentDeliveries: [ - { event: "document.processed", status: 200, time: "9m ago" }, - { event: "pipeline.completed", status: 200, time: "22m ago" }, - { event: "document.processed", status: 503, time: "1h ago" }, - ], - }, - }, - { - id: "src-webhook-err", - name: "Legacy ERP callback", - type: "webhook", - status: "error", - docs24h: 41, - docs30d: 1180, - lastEvent: "2m ago", - owner: "integrations@acme.com", - // Endpoint is rejecting most deliveries — success rate below the 95% floor - // drives the danger tone and the retry count climbs. - detail: { - kind: "webhook", - url: "https://erp.acme.com/inbound/stirling", - authType: "Basic", - successRate: 0.72, - retries24h: 58, - recentDeliveries: [ - { event: "document.processed", status: 502, time: "2m ago" }, - { event: "document.processed", status: 502, time: "5m ago" }, - { event: "pipeline.completed", status: 200, time: "9m ago" }, - ], - }, - }, - { - id: "src-batch-1", - name: "Nightly archive reprocess", - type: "batch", - status: "idle", - docs24h: 0, - docs30d: 48600, - lastEvent: "8h ago", - owner: "data-eng@acme.com", - detail: { - kind: "basic", - rows: [ - { label: "Schedule", value: "Daily · 02:00 UTC" }, - { label: "Last run", value: "1,620 docs · 0 errors" }, - { label: "Source bucket", value: "s3://acme-archive/inbound" }, - { label: "Pipeline", value: "OCR & Index" }, - ], - }, - }, -]; - -const ENTERPRISE_EXTRA: Source[] = [ - { - id: "src-connector-1", - name: "SharePoint — Legal", - type: "connector", - status: "active", - docs24h: 1840, - docs30d: 44900, - lastEvent: "2m ago", - owner: "legal-ops@acme.com", - detail: { - kind: "basic", - rows: [ - { label: "Provider", value: "Microsoft SharePoint" }, - { label: "Sync", value: "Delta · every 5 min" }, - { label: "Watched library", value: "Contracts / Inbound" }, - { label: "Auth", value: "Azure AD app registration" }, - ], - }, - }, - { - id: "src-connector-2", - name: "S3 — claims-intake", - type: "connector", - status: "active", - docs24h: 5210, - docs30d: 128400, - lastEvent: "40s ago", - owner: "claims@acme.com", - detail: { - kind: "basic", - rows: [ - { label: "Provider", value: "AWS S3" }, - { label: "Bucket", value: "s3://acme-claims/intake" }, - { label: "Notification", value: "EventBridge → SQS" }, - { label: "Region", value: "us-east-1" }, - ], - }, - }, - { - id: "src-email-1", - name: "invoices@acme.com", - type: "email", - status: "active", - docs24h: 318, - docs30d: 7640, - lastEvent: "14m ago", - owner: "ap@acme.com", - detail: { - kind: "basic", - rows: [ - { label: "Inbox", value: "invoices@acme.com" }, - { label: "Attachments", value: "PDF only · max 25 MB" }, - { label: "Pipeline", value: "Invoice v3" }, - { label: "Spam filter", value: "Enabled · DKIM verified" }, - ], - }, - }, - { - id: "src-desktop-1", - name: "Stirling Desktop — Reviewer pool", - type: "desktop", - status: "idle", - docs24h: 47, - docs30d: 1290, - lastEvent: "3h ago", - owner: "review-team@acme.com", - detail: { - kind: "basic", - rows: [ - { label: "App version", value: "Desktop 2.7.1" }, - { label: "Seats", value: "12 active devices" }, - { label: "Default pipeline", value: "Manual Review" }, - { label: "Offline queue", value: "Enabled" }, - ], - }, - }, - { - id: "src-agent-4", - name: "Compliance Sweep", - type: "agent", - status: "active", - docs24h: 2105, - docs30d: 52300, - lastEvent: "18s ago", - owner: "compliance@acme.com", - detail: agentDetail({ - model: "claude-opus-4.1", - calls24h: 2180, - errorRate: 0.008, - confidence: 0.957, - escalations24h: 19, - assignedPipelines: ["COI Compliance", "PII Sweep", "Retention Policy"], - scopes: ["documents:read", "pipelines:invoke", "pii:read", "audit:write"], - }), - }, - { - id: "src-webhook-2", - name: "Datadog events hook", - type: "webhook", - status: "active", - docs24h: 410, - docs30d: 9800, - lastEvent: "1m ago", - owner: "sre@acme.com", - detail: { - kind: "webhook", - url: "https://intake.datadoghq.com/stirling/events", - authType: "Bearer token", - successRate: 0.999, - retries24h: 0, - recentDeliveries: [ - { event: "pipeline.completed", status: 202, time: "1m ago" }, - { event: "agent.escalated", status: 202, time: "12m ago" }, - { event: "pipeline.failed", status: 202, time: "47m ago" }, - ], - }, - }, - { - id: "src-api-revoked", - name: "Legacy ETL (revoked)", - type: "apiclient", - status: "error", - docs24h: 0, - docs30d: 3120, - lastEvent: "5d ago", - owner: "data-eng@acme.com", - // Key was revoked after a leak; calls now 401 so 24h traffic is zero while - // the 30d window still shows the pre-revocation volume. - detail: { - kind: "apiclient", - maskedKey: "sk_live_••••••••••••0000 (revoked)", - rateLimit: "300 req/min", - rateUsedPct: 0, - endpoints: [ - { method: "POST", path: "/v1/extract", calls24h: 0 }, - { method: "POST", path: "/v1/redact", calls24h: 0 }, - ], - createdBy: "former-admin@acme.com", - lastRotated: "never", - }, - }, - { - id: "src-api-2", - name: "Partner Integration (read-only)", - type: "apiclient", - status: "paused", - docs24h: 0, - docs30d: 14200, - lastEvent: "2d ago", - owner: "partnerships@acme.com", - detail: { - kind: "apiclient", - maskedKey: "sk_live_••••••••••••91c2", - rateLimit: "120 req/min", - rateUsedPct: 0, - endpoints: [ - { method: "GET", path: "/v1/documents/{id}", calls24h: 0 }, - { method: "GET", path: "/v1/pipelines", calls24h: 0 }, - ], - createdBy: "you@acme.com", - lastRotated: "61 days ago", - }, - }, -]; - -/** Sources for a given tier. Free is intentionally empty. */ -export function sourcesFor(tier: Tier): Source[] { - if (tier === "free") return []; - if (tier === "enterprise") return [...PRO_SOURCES, ...ENTERPRISE_EXTRA]; - return PRO_SOURCES; -} - -/** - * KPI strip values. Pro sits below the eval pass-rate target, enterprise above — - * the delta/direction differences let a dev see tier variation at a glance. - */ -export function kpisFor(tier: Tier): SourcesKpi[] { - if (tier === "free") { - return [ - { value: 0, description: "Connect a source to begin" }, - { value: 0 }, - { value: "—" }, - { value: 0 }, - ]; - } - - const sources = sourcesFor(tier); - const agents = sources.filter((s) => s.type === "agent"); - const agentsActive = agents.filter((s) => s.status === "active").length; - const docs24h = sources.reduce((sum, s) => sum + s.docs24h, 0); - - if (tier === "enterprise") { - return [ - { - value: agentsActive, - delta: 0.25, - description: `${agents.length} total`, - }, - { value: 42, delta: 0.09, description: "Eval scenarios" }, - { value: "96.4%", deltaDirection: "up", delta: 0.012 }, - { value: docs24h.toLocaleString(), delta: 0.14 }, - ]; - } - - // pro - return [ - { value: agentsActive, delta: 0.5, description: `${agents.length} total` }, - { value: 18, delta: 0.2, description: "Eval scenarios" }, - { value: "91.2%", deltaDirection: "flat", delta: 0 }, - { value: docs24h.toLocaleString(), delta: 0.16 }, - ]; -} - -export function buildSourcesResponse(tier: Tier): SourcesResponse { - return { kpis: kpisFor(tier), sources: sourcesFor(tier) }; -} diff --git a/frontend/portal/src/setupTests.ts b/frontend/portal/src/setupTests.ts new file mode 100644 index 0000000000..9064c734ba --- /dev/null +++ b/frontend/portal/src/setupTests.ts @@ -0,0 +1,64 @@ +import "@testing-library/jest-dom"; +import { vi } from "vitest"; + +// Mirrors the editor's setup: jsdom lacks a handful of browser APIs that shared +// components (Mantine FocusTrap, responsive helpers) touch on render. + +class LocalStorageMock implements Storage { + private store: Record = {}; + + get length(): number { + return Object.keys(this.store).length; + } + + clear(): void { + this.store = {}; + } + + getItem(key: string): string | null { + return this.store[key] ?? null; + } + + key(index: number): string | null { + return Object.keys(this.store)[index] ?? null; + } + + removeItem(key: string): void { + delete this.store[key]; + } + + setItem(key: string, value: string): void { + this.store[key] = value; + } +} + +Object.defineProperty(window, "localStorage", { + value: new LocalStorageMock(), + writable: true, +}); + +global.ResizeObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})); + +global.IntersectionObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})) as unknown as typeof IntersectionObserver; + +Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); diff --git a/frontend/portal/src/views/Sources.test.tsx b/frontend/portal/src/views/Sources.test.tsx new file mode 100644 index 0000000000..98ce7175e4 --- /dev/null +++ b/frontend/portal/src/views/Sources.test.tsx @@ -0,0 +1,130 @@ +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 { SourcesResponse } from "@portal/api/sources"; +import { Sources } from "@portal/views/Sources"; + +// 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 fetchSources = vi.fn(); +const fetchSource = vi.fn(); +const createSource = vi.fn(); +const deleteSource = vi.fn(); +vi.mock("@portal/api/sources", () => ({ + fetchSources: () => fetchSources(), + fetchSource: (id: string) => fetchSource(id), + createSource: (source: unknown) => createSource(source), + deleteSource: (id: string) => deleteSource(id), +})); + +const RESPONSE: SourcesResponse = { + kpis: [ + { value: 2, description: "" }, + { value: 1, description: "" }, + { value: 1, description: "" }, + ], + sources: [ + { + id: "src-referenced", + name: "Claims intake", + type: "folder", + status: "active", + referenceCount: 2, + referencingPolicies: [ + { id: "pol-1", name: "Redaction" }, + { id: "pol-2", name: "Classification" }, + ], + config: [{ label: "Directory", value: "/data/incoming" }], + docsTotal: null, + }, + { + id: "src-orphan", + name: "Scratch folder", + type: "folder", + status: "unused", + referenceCount: 0, + referencingPolicies: [], + config: [{ label: "Directory", value: "/tmp/scratch" }], + docsTotal: null, + }, + ], +}; + +function renderView() { + return render( + + + , + ); +} + +describe("Sources view", () => { + beforeEach(() => { + fetchSources.mockReset(); + fetchSource.mockReset(); + createSource.mockReset(); + deleteSource.mockReset(); + }); + + it("surfaces the inline 409 message when deleting a referenced source", async () => { + fetchSources.mockResolvedValue(RESPONSE); + deleteSource.mockRejectedValue( + new HttpError(409, "Conflict", { + detail: "Source is referenced by 2 policies", + }), + ); + + renderView(); + + // Wait for the row to render after the async fetch resolves. + const row = await screen.findByText("Claims intake"); + fireEvent.click(row); + + // Detail card opens with its delete action. + fireEvent.click(await screen.findByText("sources.detail.delete")); + + // Confirm in the dialog. + fireEvent.click(await screen.findByText("sources.delete.confirm")); + + await waitFor(() => { + expect(deleteSource).toHaveBeenCalledWith("src-referenced"); + }); + + expect( + await screen.findByText("Source is referenced by 2 policies"), + ).toBeInTheDocument(); + }); + + it("pauses a source by re-saving it with enabled flipped off", async () => { + fetchSources.mockResolvedValue(RESPONSE); + fetchSource.mockResolvedValue({ + id: "src-referenced", + name: "Claims intake", + type: "folder", + options: { directory: "/data/incoming", mode: "consume" }, + enabled: true, + }); + createSource.mockResolvedValue({}); + + renderView(); + + fireEvent.click(await screen.findByText("Claims intake")); + fireEvent.click(await screen.findByText("sources.detail.pause")); + + await waitFor(() => { + expect(createSource).toHaveBeenCalledTimes(1); + }); + expect(fetchSource).toHaveBeenCalledWith("src-referenced"); + expect(createSource).toHaveBeenCalledWith( + expect.objectContaining({ id: "src-referenced", enabled: false }), + ); + }); +}); diff --git a/frontend/portal/src/views/Sources.tsx b/frontend/portal/src/views/Sources.tsx index 02c427fde1..230dc3abb7 100644 --- a/frontend/portal/src/views/Sources.tsx +++ b/frontend/portal/src/views/Sources.tsx @@ -1,10 +1,24 @@ -import { useState } from "react"; +import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Button, EmptyState, Skeleton } from "@shared/components"; -import { useTier } from "@portal/contexts/TierContext"; +import { + Banner, + Button, + EmptyState, + Modal, + Skeleton, +} from "@shared/components"; import { useView } from "@portal/contexts/ViewContext"; import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; -import { fetchSources, type SourcesResponse } from "@portal/api/sources"; +import { errorMessage } from "@portal/api/http"; +import { + createSource, + deleteSource, + fetchSource, + fetchSources, + type Source, + type SourcesResponse, + type SourceView, +} from "@portal/api/sources"; import { AgentBuilderIcon } from "@portal/components/icons"; import { KpiStrip } from "@portal/components/sources/KpiStrip"; import { SourcesTable } from "@portal/components/sources/SourcesTable"; @@ -14,18 +28,86 @@ import "@portal/views/Sources.css"; export function Sources() { const { t } = useTranslation(); - const { tier } = useTier(); const { setActiveView } = useView(); - const state = useAsync(() => fetchSources(tier), [tier]); + // Refetch after every mutation by bumping this counter, so the table reflects + // the in-memory store the handlers maintain (mirrors the Policies view). + const [version, setVersion] = useState(0); + const state = useAsync(() => fetchSources(), [version]); const { data, loading } = state; const { isLoading, isEmpty } = useSectionFlags(state); + const refetch = useCallback(() => setVersion((v) => v + 1), []); const [expandedId, setExpandedId] = useState(null); const [wizardOpen, setWizardOpen] = useState(false); + const [editingSource, setEditingSource] = useState(null); + const [mutating, setMutating] = useState(false); + const [pageError, setPageError] = useState(null); + const [pendingDelete, setPendingDelete] = useState(null); + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); const sources = data?.sources ?? []; const expanded = sources.find((s) => s.id === expandedId) ?? null; + function openCreate() { + setEditingSource(null); + setWizardOpen(true); + } + + // Editing needs the raw source (config options), which the overview rows don't + // carry, so fetch it before opening the wizard prefilled. + async function openEdit(source: SourceView) { + if (mutating) return; + setPageError(null); + setMutating(true); + try { + setEditingSource(await fetchSource(source.id)); + setWizardOpen(true); + } catch (e) { + setPageError(errorMessage(e)); + } finally { + setMutating(false); + } + } + + // Pause/resume: re-save the source with enabled flipped (same POST contract as + // edit). Fetch the raw record first so the full config round-trips intact. + async function togglePause(source: SourceView) { + if (mutating) return; + setPageError(null); + setMutating(true); + try { + const raw = await fetchSource(source.id); + await createSource({ ...raw, enabled: !raw.enabled }); + refetch(); + } catch (e) { + setPageError(errorMessage(e)); + } finally { + setMutating(false); + } + } + + function requestDelete(source: SourceView) { + setDeleteError(null); + setPendingDelete(source); + } + + async function confirmDelete() { + if (!pendingDelete || deleting) return; + setDeleting(true); + setDeleteError(null); + try { + await deleteSource(pendingDelete.id); + setPendingDelete(null); + setExpandedId(null); + refetch(); + } catch (e) { + setDeleteError(errorMessage(e)); + } finally { + setDeleting(false); + } + } + return (
@@ -41,15 +123,14 @@ export function Sources() { > {t("sources.actions.agentBuilder")} -
+ {pageError && } + {isLoading && ( @@ -65,7 +146,7 @@ export function Sources() { title={t("sources.empty.title")} description={t("sources.empty.description")} actions={ - } @@ -86,10 +167,49 @@ export function Sources() { setExpandedId(null)} + onEdit={openEdit} + onTogglePause={togglePause} + onDelete={requestDelete} + busy={mutating} /> )} - setWizardOpen(false)} /> + setWizardOpen(false)} + onCreated={refetch} + /> + + !deleting && setPendingDelete(null)} + width="sm" + title={t("sources.delete.title")} + footer={ +
+ + +
+ } + > +

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

+ {deleteError && } +
); } diff --git a/frontend/portal/vitest.config.ts b/frontend/portal/vitest.config.ts new file mode 100644 index 0000000000..a34f840b89 --- /dev/null +++ b/frontend/portal/vitest.config.ts @@ -0,0 +1,38 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react-swc"; +import tsconfigPaths from "vite-tsconfig-paths"; +import { resolve } from "node:path"; + +// Standalone test config for the portal app, mirroring the editor's setup. +// Explicit resolve.alias for @portal and @shared so imports resolve even when +// they originate inside frontend/shared/ (outside the portal tsconfig scope), +// matching the resolve.alias block in the portal's vite.config.ts. +const portalDir = resolve(__dirname, "src"); +const sharedDir = resolve(__dirname, "..", "shared"); + +export default defineConfig({ + plugins: [ + react(), + tsconfigPaths({ + projects: [resolve(__dirname, "tsconfig.json")], + }), + ], + resolve: { + alias: { + "@portal": portalDir, + "@shared": sharedDir, + }, + }, + test: { + globals: true, + environment: "jsdom", + setupFiles: ["./src/setupTests.ts"], + css: false, + include: ["src/**/*.test.{ts,tsx}"], + testTimeout: 10000, + hookTimeout: 10000, + }, + esbuild: { + target: "es2020", + }, +}); diff --git a/frontend/shared/i18n/translationAudit.ts b/frontend/shared/i18n/translationAudit.ts index 0140a15699..50cd1b2c76 100644 --- a/frontend/shared/i18n/translationAudit.ts +++ b/frontend/shared/i18n/translationAudit.ts @@ -102,7 +102,12 @@ export const I18N_PROJECTS: TranslationProject[] = [ srcRoot: front("portal/src"), extraRoots: [SHARED_SRC], localeFile: front("portal/public/locales/en-US/translation.toml"), - ignoredKeyPatterns: [], + ignoredKeyPatterns: [ + // Source-type copy is referenced via metadata keys in + // components/sources/sourceTypes.ts (t(field.labelKey)), so the static + // scan can't see these as used. + /^sources\.types\./, + ], minUsedKeys: 20, minLocaleKeys: 20, },