Add sources service and frontend (#6774)

# Description of Changes
Redesign policies backend to treat sources a lot closer to how the
frontend imagined them working (they're persistent now and have an API).
Then connect the portal to the sources when mocks are off to allow for
source creation in the UI. It's not particularly useful to do that right
now because there's no policies UI, but I've tested manually that
sources set up in the UI are usable by policies created via the API.

I had to change the portal so that when mocks are off, it doesn't just
hard crash when attempting to connect to all the backend APIs that don't
exist yet. It'll still log the errors, but just continues on rendering
the UI now.

I also changed all the policies backend APIs to be gated behind a flag
instead of behind the SaaS profile. This is because we haven't yet got
the payment model sorted, but we're going to need this stuff running
self-hosted to be able to test it locally.
This commit is contained in:
James Brunton
2026-06-26 13:21:53 +00:00
committed by GitHub
parent def3cf79f6
commit 3f7e898c69
84 changed files with 3090 additions and 1349 deletions
+4 -2
View File
@@ -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:
+12
View File
@@ -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]
+4
View File
@@ -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}}'
@@ -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
@@ -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<Path> allowedRoots;
private final List<Path> 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;
@@ -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<Policy> visible(List<Policy> 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<Policy> 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() {
@@ -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<Policy> 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<Policy> 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();
@@ -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
@@ -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<String, PolicyRun> runs = new ConcurrentHashMap<>();
@@ -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 <em>when</em>
* 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 <em>when</em> 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<InputSource> 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<InputSpec> sources = policy.sources();
if (sources.isEmpty()) {
List<String> 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());
}
}
@@ -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<PolicyTrigger> triggers;
private final List<InputSource> inputSources;
private final List<PolicyOutputSink> 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());
}
@@ -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;
@@ -6,8 +6,9 @@ import java.util.List;
* A stored automation: ordered tool steps, input sources, and an output destination.
*
* <p>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<InputSpec> sources,
List<String> sourceIds,
List<PipelineStep> 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<InputSpec> sources,
List<String> sourceIds,
List<PipelineStep> 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). */
@@ -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;
@@ -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";
@@ -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<String, Source> 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<Source> get(String id) {
return Optional.ofNullable(sources.get(id));
}
@Override
public List<Source> all() {
return List.copyOf(sources.values());
}
@Override
public List<Source> 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;
}
}
@@ -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<Source> get(String id) {
return repository.findById(id).map(this::toSource);
}
@Override
public List<Source> all() {
return repository.findAll().stream().map(this::toSource).toList();
}
@Override
public List<Source> 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);
}
}
@@ -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.
*
* <p>{@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<String, Object> 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);
}
}
@@ -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<Source> visibleFrom(SourceStore store) {
if (!enforced()) {
return store.all();
}
return store.findByTeam(policyManagementAuthority.currentUserTeamId());
}
private boolean enforced() {
return applicationProperties.getSecurity().isEnableLogin();
}
}
@@ -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<InputSource> 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<Source> 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<Source> 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<Void> delete(@PathVariable String sourceId) {
requireSourceEditingAllowed();
Source source = sourceStore.get(sourceId).filter(sourceAccessGuard::canAccess).orElse(null);
if (source == null) {
return ResponseEntity.notFound().build();
}
List<String> 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<String> referencingPolicyNames(String sourceId) {
return policyAccessGuard.visibleFrom(policyStore).stream()
.filter(policy -> policy.sourceIds().contains(sourceId))
.map(Policy::name)
.toList();
}
}
@@ -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;
}
@@ -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) {}
@@ -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<Source> sources = sourceAccessGuard.visibleFrom(sourceStore);
List<Policy> policies = policyAccessGuard.visibleFrom(policyStore);
Map<String, List<Policy>> referencesBySource = referencesBySource(policies);
List<SourceView> 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<String, List<Policy>> referencesBySource(List<Policy> policies) {
Map<String, List<Policy>> 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<Policy> referencingPolicies) {
List<SourceView.PolicyRef> 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<SourceView.DetailRow> 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<SourceKpi> buildKpis(List<SourceView> 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"));
}
}
@@ -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<SourceEntity, String> {
/**
* 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<SourceEntity> findByTeam(@Param("teamId") Long teamId);
}
@@ -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<Source> get(String id);
List<Source> all();
/** Sources owned by the given team, loaded scoped rather than fetched globally. */
List<Source> findByTeam(Long teamId);
/** Returns whether the source existed. */
boolean delete(String id);
}
@@ -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<PolicyRef> referencingPolicies,
List<DetailRow> 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) {}
}
@@ -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<SourceKpi> kpis, List<SourceView> sources) {}
@@ -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<Policy> findByTeam(Long teamId) {
return policies.values().stream()
.filter(policy -> Objects.equals(policy.teamId(), teamId))
.toList();
}
@Override
public List<Policy> findByTriggerType(String triggerType) {
return policies.values().stream()
@@ -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<Policy> findByTeam(Long teamId) {
return repository.findByTeam(teamId).stream().map(this::toPolicy).toList();
}
@Override
public List<Policy> findByTriggerType(String triggerType) {
return repository.findByTriggerTypeAndEnabledTrue(triggerType).stream()
@@ -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;
}
@@ -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<PolicyEntity, String> {
/** Enabled policies of a given trigger type, for background triggers to activate. */
List<PolicyEntity> 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<PolicyEntity> findByTeam(@Param("teamId") Long teamId);
}
@@ -15,6 +15,9 @@ public interface PolicyStore {
List<Policy> all();
/** Policies owned by the given team, loaded scoped rather than fetched globally. */
List<Policy> findByTeam(Long teamId);
/** Enabled policies with the given trigger type, for background triggers. */
List<Policy> findByTriggerType(String triggerType);
@@ -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<InputSource> inputSources;
private final SourceStore sourceStore;
private final ApplicationProperties applicationProperties;
private final Map<Path, WatchKey> 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<Path> watchDirsOf(Policy policy) {
List<Path> 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());
}
}
@@ -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() {}
}
@@ -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<PolicyTrigger> 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);
}
}
}
}
@@ -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";
@@ -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 {
@@ -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<String> 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<InputSpec> sources, OutputSpec output) {
return new Policy("p1", "p", "owner", true, null, sources, List.of(), output);
private Policy policy(List<InputSpec> sources, OutputSpec output) {
List<String> 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);
}
}
@@ -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<Policy> all = List.of(inTeam(1L), inTeam(2L), inTeam(1L), inTeam(null));
List<Policy> 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<Policy> visible = guard(true).visibleFrom(store);
assertEquals(2, visible.size());
assertTrue(visible.stream().allMatch(p -> Long.valueOf(1L).equals(p.teamId())));
}
@Test
void visibleReturnsEverythingWhenLoginDisabled() {
List<Policy> 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);
}
}
@@ -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<Policy> 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<Policy> result = controller.listPolicies();
@@ -355,6 +365,7 @@ class PolicyControllerTest {
ResponseEntity<Void> 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
@@ -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<InputSpec> sources) {
/** Persists each spec as a source and returns a policy referencing them by id. */
private Policy policy(List<InputSpec> sources) {
List<String> 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);
}
}
@@ -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();
}
}
@@ -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);
@@ -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
@@ -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<SourceEntity> 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;
}
}
@@ -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<Void> 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());
}
}
@@ -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();
}
}
@@ -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<PolicyEntity> 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<Policy> 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;
}
@@ -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<InputSpec> sources) {
/** Persists each spec as a source and returns a folder-watch policy referencing them by id. */
private Policy folderWatch(String id, List<InputSpec> sources) {
List<String> 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());
}
@@ -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.
@@ -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);
@@ -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"
+17 -2
View File
@@ -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 <SettingsModal open={settingsOpen} onClose={closeSettings} />;
}
/**
* 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 (
<ErrorBoundary key={pathname}>
<ViewRouter />
</ErrorBoundary>
);
}
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() {
<GlobalShortcuts />
<AuthGate>
<AppShell>
<ViewRouter />
<RoutedContent />
</AppShell>
<AssistantButton />
<AssistantPanel />
+18
View File
@@ -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<string, string> {
const token = getStoredToken();
return token ? { Authorization: `Bearer ${token}` } : {};
+76 -21
View File
@@ -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<SourcesResponse> {
return httpJson<SourcesResponse>(
`/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<string, unknown>;
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<SourcesResponse> {
return httpJson<SourcesResponse>("/api/v1/sources");
}
/** GET /api/v1/sources/{id}: the raw source record (config options), for editing. */
export async function fetchSource(id: string): Promise<Source> {
return httpJson<Source>(`/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<Source> {
return httpJson<Source>("/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<void> {
await httpJson<void>(`/api/v1/sources/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
@@ -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(
<ErrorBoundary>
<Boom />
</ErrorBoundary>,
);
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 <div>recovered content</div>;
}
render(
<ErrorBoundary>
<Toggleable />
</ErrorBoundary>,
);
// 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();
});
});
@@ -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 (
<EmptyState
title={t("errorBoundary.title")}
description={t("errorBoundary.description")}
actions={<Button onClick={onRetry}>{t("errorBoundary.retry")}</Button>}
/>
);
}
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 (
<div style={{ padding: "2rem" }}>
<DefaultErrorFallback onRetry={this.reset} />
</div>
);
}
}
@@ -125,7 +125,7 @@ export function PolicySummary() {
},
];
const rows: PolicyRow[] = data?.catalogue.map(toRow) ?? [];
const rows: PolicyRow[] = data?.catalogue?.map(toRow) ?? [];
return (
<section className="portal-policysum" aria-label={t("policySummary.title")}>
@@ -139,7 +139,7 @@ export function PolicySummary() {
{t("policySummary.subtitle")}
</p>
</div>
{data && (
{data?.summary && (
<StatusBadge tone="info" size="sm">
{t("policySummary.activeSummary", {
active: data.summary.active,
@@ -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<typeof AgentPanel> = {
title: "Portal/Sources/AgentPanel",
component: AgentPanel,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "48rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof AgentPanel>;
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,
},
},
};
@@ -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 (
<div className="portal-sources__detail">
<div className="portal-sources__stat-grid">
<StatTile
label={t("sources.agent.model")}
value={<code>{d.model}</code>}
/>
<StatTile
label={t("sources.agent.calls24h")}
value={d.calls24h.toLocaleString()}
/>
<StatTile
label={t("sources.agent.errorRate")}
value={
<StatusBadge tone={errorTone} size="sm">
{pct(d.errorRate)}
</StatusBadge>
}
/>
<StatTile
label={t("sources.agent.escalations24h")}
value={d.escalations24h}
/>
</div>
<div className="portal-sources__bar-row">
<div className="portal-sources__bar-head">
<span>{t("sources.agent.meanConfidence")}</span>
<strong>{pct(d.confidence)}</strong>
</div>
<ProgressBar
value={d.confidence}
color={
d.confidence >= 0.93 ? "var(--color-green)" : "var(--color-amber)"
}
label={t("sources.agent.meanOutputConfidence")}
/>
</div>
<div className="portal-sources__detail-section">
<span className="portal-sources__detail-heading">
{t("sources.agent.assignedPipelines")}
</span>
<div className="portal-sources__chips">
{d.assignedPipelines.map((p) => (
<Chip key={p} tone="blue" size="sm">
{p}
</Chip>
))}
</div>
</div>
<div className="portal-sources__detail-section">
<span className="portal-sources__detail-heading">
{t("sources.agent.scopes")}
</span>
<div className="portal-sources__chips">
{d.scopes.map((s) => (
<Chip key={s} tone="neutral" size="sm">
{s}
</Chip>
))}
</div>
</div>
{/* TODO(backend): wire to GET /v1/sources/{id}/eval-runs and
POST /v1/sources/{id}/pause — currently inert demo controls. */}
<div className="portal-sources__detail-actions">
<Button size="sm" variant="outline">
{t("sources.agent.viewEvalRuns")}
</Button>
<Button size="sm" variant="ghost">
{t("sources.agent.pauseAgent")}
</Button>
</div>
</div>
);
}
@@ -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<typeof ApiClientPanel> = {
title: "Portal/Sources/ApiClientPanel",
component: ApiClientPanel,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "48rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof ApiClientPanel>;
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 })),
},
},
};
@@ -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 (
<div className="portal-sources__detail">
<div className="portal-sources__stat-grid">
<StatTile
label={t("sources.apiClient.secretKey")}
value={<code>{d.maskedKey}</code>}
/>
<StatTile
label={t("sources.apiClient.rateLimit")}
value={d.rateLimit}
/>
<StatTile
label={t("sources.apiClient.createdBy")}
value={d.createdBy}
/>
<StatTile
label={t("sources.apiClient.lastRotated")}
value={d.lastRotated}
/>
</div>
<div className="portal-sources__bar-row">
<div className="portal-sources__bar-head">
<span>{t("sources.apiClient.rateLimitWindow")}</span>
<strong>
{t("sources.apiClient.usedPct", { pct: pct(d.rateUsedPct) })}
</strong>
</div>
<ProgressBar
value={d.rateUsedPct}
thresholded
label={t("sources.apiClient.rateLimitUsage")}
/>
</div>
<div className="portal-sources__detail-section">
<span className="portal-sources__detail-heading">
{t("sources.apiClient.topEndpoints")}
</span>
<div className="portal-sources__endpoints">
{d.endpoints.map((e) => (
<div key={e.path} className="portal-sources__endpoint">
<Chip
tone={e.method === "GET" ? "green" : "blue"}
size="sm"
className="portal-sources__method"
>
{e.method}
</Chip>
<code className="portal-sources__endpoint-path">{e.path}</code>
<span className="portal-sources__endpoint-calls">
{t("sources.apiClient.callsPer24h", {
count: e.calls24h.toLocaleString(),
})}
</span>
</div>
))}
</div>
</div>
{/* TODO(backend): wire to POST /v1/sources/{id}/rotate-key and
DELETE /v1/sources/{id} — currently inert demo controls. */}
<div className="portal-sources__detail-actions">
<Button size="sm" variant="outline" accent="amber">
{t("sources.apiClient.rotateKey")}
</Button>
<Button size="sm" variant="ghost" accent="red">
{t("sources.apiClient.revoke")}
</Button>
</div>
</div>
);
}
@@ -5,7 +5,7 @@ const meta: Meta<typeof ConnectWizard> = {
title: "Portal/Sources/ConnectWizard",
component: ConnectWizard,
parameters: { layout: "fullscreen" },
args: { open: true, onClose: () => {} },
args: { open: true, onClose: () => {}, onCreated: () => {} },
};
export default meta;
type Story = StoryObj<typeof ConnectWizard>;
@@ -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(<ConnectWizard open onClose={onClose} onCreated={onCreated} />);
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(
<ConnectWizard
open
source={{
id: "s1",
name: "James",
type: "folder",
options: { directory: "'/data/in'", mode: "consume" },
enabled: true,
}}
onClose={vi.fn()}
onCreated={onCreated}
/>,
);
// 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(<ConnectWizard open onClose={vi.fn()} onCreated={vi.fn()} />);
stepToReview();
fireEvent.click(screen.getByText("sources.actions.connectSource"));
expect(
await screen.findByText("Directory is not readable"),
).toBeInTheDocument();
});
});
@@ -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<string, unknown> | undefined,
): Record<string, string> {
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<Source["type"]>("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<CreatableSourceType>(() =>
typeFor(source?.type),
);
const [name, setName] = useState(source?.name ?? "");
const [options, setOptions] = useState<Record<string, string>>(() =>
optionsFor(typeFor(source?.type), source?.options),
);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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<StepId, string> = {
type: t("sources.wizard.steps.chooseType"),
configure: t("sources.wizard.steps.configure"),
review: t("sources.wizard.steps.review"),
};
return (
<Modal
open={open}
onClose={close}
onClose={onClose}
width="lg"
title={t("sources.wizard.title")}
title={isEdit ? t("sources.wizard.editTitle") : t("sources.wizard.title")}
subtitle={t("sources.wizard.subtitle", {
current: step + 1,
total: WIZARD_STEP_COUNT,
label: wizardSteps[step],
current: stepIndex + 1,
total: steps.length,
label: stepLabels[stepId],
})}
footer={
<div className="portal-sources__wizard-footer">
<Button
variant="ghost"
size="sm"
onClick={() => (step === 0 ? close() : setStep((s) => s - 1))}
disabled={submitting}
onClick={() =>
stepIndex === 0 ? onClose() : setStepIndex((i) => i - 1)
}
>
{step === 0 ? t("sources.wizard.cancel") : t("sources.wizard.back")}
{stepIndex === 0
? t("sources.wizard.cancel")
: t("sources.wizard.back")}
</Button>
<Button
size="sm"
onClick={advance}
loading={submitting}
disabled={!canContinue}
trailingIcon={!isLast ? <span aria-hidden></span> : undefined}
>
{isLast
? t("sources.actions.connectSource")
: t("sources.wizard.continue")}
{!isLast
? t("sources.wizard.continue")
: isEdit
? t("sources.wizard.save")
: t("sources.actions.connectSource")}
</Button>
</div>
}
>
<ol className="portal-sources__steps" aria-hidden>
{wizardSteps.map((label, i) => (
{steps.map((id, i) => (
<li
key={label}
key={id}
className={
"portal-sources__step" +
(i === step ? " is-active" : i < step ? " is-done" : "")
(i === stepIndex ? " is-active" : i < stepIndex ? " is-done" : "")
}
>
<span className="portal-sources__step-mark">
{i < step ? "✓" : i + 1}
{i < stepIndex ? "✓" : i + 1}
</span>
{label}
{stepLabels[id]}
</li>
))}
</ol>
{step === 0 && (
{stepId === "type" && (
<div className="portal-sources__type-grid">
{(Object.keys(SOURCE_TYPE_META) as Source["type"][]).map((t) => {
const meta = SOURCE_TYPE_META[t];
return (
<button
key={t}
type="button"
className={
"portal-sources__type-card" +
(type === t ? " is-selected" : "")
}
onClick={() => setType(t)}
>
<span className="portal-sources__type-icon" aria-hidden>
{meta.icon}
</span>
<span className="portal-sources__type-name">{meta.label}</span>
</button>
);
})}
{CREATABLE_SOURCE_TYPES.map((ct) => (
<button
key={ct.type}
type="button"
className={
"portal-sources__type-card" +
(type.type === ct.type ? " is-selected" : "")
}
onClick={() => chooseType(ct)}
>
<span className="portal-sources__type-icon" aria-hidden>
{sourceTypeMeta(ct.type).icon}
</span>
<span className="portal-sources__type-name">
{t(ct.labelKey)}
</span>
</button>
))}
</div>
)}
{step === 1 && (
{stepId === "configure" && (
<div className="portal-sources__wizard-body">
<p className="portal-sources__wizard-lead">
{t("sources.wizard.configureLead.before")}{" "}
<strong>{SOURCE_TYPE_META[type].label}</strong>
{t("sources.wizard.configureLead.after")}
</p>
<CodeBlock code={CONNECT_SNIPPET} caption="quickstart.sh" />
<p className="portal-sources__wizard-note">
{t("sources.wizard.configureNote")}
</p>
<FormField label={t("sources.wizard.name")} required>
<Input
value={name}
placeholder={t("sources.wizard.namePlaceholder")}
onChange={(e) => setName(e.target.value)}
/>
</FormField>
{type.fields.map((field) => (
<FormField
key={field.key}
label={t(field.labelKey)}
helperText={
field.helperTextKey ? t(field.helperTextKey) : undefined
}
required={field.required}
>
{field.control === "select" ? (
<Select
value={options[field.key] ?? ""}
options={(field.options ?? []).map((o) => ({
value: o.value,
label: t(o.labelKey),
}))}
onChange={(e) =>
setOptions((o) => ({ ...o, [field.key]: e.target.value }))
}
/>
) : (
<Input
value={options[field.key] ?? ""}
placeholder={
field.placeholderKey ? t(field.placeholderKey) : undefined
}
onChange={(e) =>
setOptions((o) => ({ ...o, [field.key]: e.target.value }))
}
/>
)}
</FormField>
))}
</div>
)}
{step === 2 && (
{stepId === "review" && (
<div className="portal-sources__wizard-body">
<p className="portal-sources__wizard-lead">
{t("sources.wizard.reviewLead.before")}{" "}
<strong>{SOURCE_TYPE_META[type].label}</strong>
{t("sources.wizard.reviewLead.after")}
</p>
<div className="portal-sources__stat-grid">
<StatTile label={t("sources.wizard.name")} value={name || "—"} />
<StatTile
label={t("sources.wizard.type")}
value={SOURCE_TYPE_META[type].label}
value={t(type.labelKey)}
/>
<StatTile
label={t("sources.wizard.defaultPipeline")}
value={t("sources.wizard.defaultPipelineValue")}
/>
<StatTile
label={t("sources.wizard.initialState")}
value={t("sources.wizard.initialStateValue")}
/>
<StatTile label={t("sources.wizard.region")} value="us-east-1" />
{type.fields.map((field) => (
<StatTile
key={field.key}
label={t(field.labelKey)}
value={options[field.key] || "—"}
/>
))}
</div>
{error && <Banner tone="danger" description={error} />}
</div>
)}
</Modal>
@@ -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<typeof KpiStrip> = {
title: "Portal/Sources/KpiStrip",
component: KpiStrip,
@@ -10,20 +19,11 @@ const meta: Meta<typeof KpiStrip> = {
export default meta;
type Story = StoryObj<typeof KpiStrip>;
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 },
};
@@ -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}
/>
);
@@ -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<typeof SourceDetailCard> = {
title: "Portal/Sources/SourceDetailCard",
component: SourceDetailCard,
parameters: { layout: "padded" },
args: { onClose: () => {} },
args: {
onClose: () => {},
onEdit: () => {},
onTogglePause: () => {},
onDelete: () => {},
},
decorators: [
(S) => (
<div style={{ maxWidth: "56rem" }}>
@@ -23,6 +51,5 @@ const meta: Meta<typeof SourceDetailCard> = {
export default meta;
type Story = StoryObj<typeof SourceDetailCard>;
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 } };
@@ -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 (
<section className="portal-sources__expanded">
<header className="portal-sources__expanded-head">
@@ -24,9 +39,9 @@ export function SourceDetailCard({ source, onClose }: SourceDetailCardProps) {
<div>
<h2 className="portal-sources__expanded-title">{source.name}</h2>
<span className="portal-sources__expanded-sub">
{t("sources.detail.ownedBy", {
type: meta.label,
owner: source.owner,
{t("sources.detail.subtitle", {
type: t(meta.labelKey),
status: t(`sources.status.${source.status}`),
})}
</span>
</div>
@@ -39,7 +54,33 @@ export function SourceDetailCard({ source, onClose }: SourceDetailCardProps) {
×
</button>
</header>
<SourceDetailPanel source={source} />
<div className="portal-sources__detail-actions">
<Button
variant="outline"
disabled={busy}
onClick={() => onEdit(source)}
>
{t("sources.detail.edit")}
</Button>
<Button
variant="outline"
disabled={busy}
onClick={() => onTogglePause(source)}
>
{paused ? t("sources.detail.resume") : t("sources.detail.pause")}
</Button>
<Button
accent="red"
variant="outline"
disabled={busy}
onClick={() => onDelete(source)}
>
{t("sources.detail.delete")}
</Button>
</div>
</section>
);
}
@@ -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<typeof SourceDetailPanel> = {
title: "Portal/Sources/SourceDetailPanel",
@@ -22,8 +45,6 @@ const meta: Meta<typeof SourceDetailPanel> = {
export default meta;
type Story = StoryObj<typeof SourceDetailPanel>;
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 } };
@@ -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 (
<div className="portal-sources__detail">
<div className="portal-sources__stat-grid">
{rows.map((r) => (
<StatTile key={r.label} label={r.label} value={r.value} />
))}
{source.config.length > 0 && (
<div className="portal-sources__stat-grid">
{source.config.map((row) => (
<StatTile key={row.label} label={row.label} value={row.value} />
))}
</div>
)}
<div className="portal-sources__detail-section">
<span className="portal-sources__detail-heading">
{t("sources.detail.usedBy")}
</span>
{source.referencingPolicies.length === 0 ? (
<p className="portal-sources__muted">
{t("sources.detail.notReferenced")}
</p>
) : (
<div className="portal-sources__chips">
{source.referencingPolicies.map((policy) => (
<Chip key={policy.id} tone="blue" size="sm">
{policy.name}
</Chip>
))}
</div>
)}
</div>
<p className="portal-sources__muted">
{t("sources.detail.docsUntracked")}
</p>
</div>
);
}
/** 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 <AgentPanel d={detail} />;
case "apiclient":
return <ApiClientPanel d={detail} />;
case "webhook":
return <WebhookPanel d={detail} />;
case "basic":
return <BasicPanel rows={detail.rows} />;
}
}
@@ -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<typeof SourcesTable> = {
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<typeof SourcesTable>;
@@ -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 },
};
@@ -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<SourceStatus, StatusTone> = {
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<TableColumn<Source>[]>(
const columns = useMemo<TableColumn<SourceView>[]>(
() => [
{
key: "name",
header: t("sources.table.source"),
render: (s) => {
const meta = SOURCE_TYPE_META[s.type];
const meta = sourceTypeMeta(s.type);
return (
<div className="portal-sources__name-cell">
<span
@@ -39,7 +48,7 @@ export function SourcesTable({
<div className="portal-sources__name-text">
<strong>{s.name}</strong>
<Chip tone={meta.tone} size="sm">
{meta.label}
{t(meta.labelKey)}
</Chip>
</div>
</div>
@@ -51,38 +60,28 @@ export function SourcesTable({
header: t("sources.table.status"),
render: (s) => (
<StatusBadge
tone={SOURCE_STATUS_TONE[s.status]}
tone={STATUS_TONE[s.status]}
size="sm"
pulse={s.status === "active"}
>
{s.status}
{t(`sources.status.${s.status}`)}
</StatusBadge>
),
},
{
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) => (
<span className="portal-sources__muted">{s.lastEvent}</span>
<span
className={
s.referenceCount === 0 ? "portal-sources__muted" : undefined
}
>
{s.referenceCount}
</span>
),
},
{
key: "owner",
header: t("sources.table.owner"),
render: (s) => <span className="portal-sources__muted">{s.owner}</span>,
},
{
key: "expand",
header: "",
@@ -104,7 +103,7 @@ export function SourcesTable({
);
return (
<Table<Source>
<Table<SourceView>
className="portal-sources__table"
columns={columns}
rows={sources}
@@ -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<typeof WebhookPanel> = {
title: "Portal/Sources/WebhookPanel",
component: WebhookPanel,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "48rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof WebhookPanel>;
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" },
],
},
},
};
@@ -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 (
<div className="portal-sources__detail">
<div className="portal-sources__stat-grid">
<StatTile
label={t("sources.webhook.endpointUrl")}
value={<code className="portal-sources__url">{d.url}</code>}
/>
<StatTile label={t("sources.webhook.authType")} value={d.authType} />
<StatTile
label={t("sources.webhook.successRate")}
value={
<StatusBadge tone={rateTone} size="sm">
{pct(d.successRate)}
</StatusBadge>
}
/>
<StatTile
label={t("sources.webhook.retries24h")}
value={d.retries24h}
/>
</div>
<div className="portal-sources__detail-section">
<span className="portal-sources__detail-heading">
{t("sources.webhook.recentDeliveries")}
</span>
<div className="portal-sources__endpoints">
{d.recentDeliveries.map((r, i) => (
<div key={i} className="portal-sources__endpoint">
<StatusBadge
tone={r.status < 300 ? "success" : "danger"}
size="sm"
showDot={false}
>
{r.status}
</StatusBadge>
<code className="portal-sources__endpoint-path">{r.event}</code>
<span className="portal-sources__endpoint-calls">{r.time}</span>
</div>
))}
</div>
</div>
{/* TODO(backend): wire to POST /v1/sources/{id}/test-event and
GET /v1/sources/{id}/signing-secret — currently inert demo controls. */}
<div className="portal-sources__detail-actions">
<Button size="sm" variant="outline">
{t("sources.webhook.sendTestEvent")}
</Button>
<Button size="sm" variant="ghost">
{t("sources.webhook.viewSigningSecret")}
</Button>
</div>
</div>
);
}
@@ -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)}%`;
}
@@ -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<string, SourceTypeMeta> = {
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<string, string> {
const out: Record<string, string> = {};
for (const field of type.fields) {
out[field.key] = field.defaultValue ?? "";
}
return out;
}
+176 -6
View File
@@ -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<string, SourcePolicyRef[]> = {
"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<string, unknown>) {
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 });
}),
];
-545
View File
@@ -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<SourceType, SourceTypeMeta> = {
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>): 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) };
}
+64
View File
@@ -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<string, string> = {};
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(),
})),
});
+130
View File
@@ -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(
<MemoryRouter>
<Sources />
</MemoryRouter>,
);
}
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 }),
);
});
});
+132 -12
View File
@@ -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<SourcesResponse>(() => 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<SourcesResponse>(() => fetchSources(), [version]);
const { data, loading } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
const refetch = useCallback(() => setVersion((v) => v + 1), []);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [wizardOpen, setWizardOpen] = useState(false);
const [editingSource, setEditingSource] = useState<Source | null>(null);
const [mutating, setMutating] = useState(false);
const [pageError, setPageError] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<SourceView | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(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 (
<div className="portal-sources">
<header className="portal-sources__head">
@@ -41,15 +123,14 @@ export function Sources() {
>
{t("sources.actions.agentBuilder")}
</Button>
<Button
onClick={() => setWizardOpen(true)}
leadingIcon={<span aria-hidden>+</span>}
>
<Button onClick={openCreate} leadingIcon={<span aria-hidden>+</span>}>
{t("sources.actions.connectSource")}
</Button>
</div>
</header>
{pageError && <Banner tone="danger" description={pageError} />}
<KpiStrip data={data} loading={loading} />
{isLoading && (
@@ -65,7 +146,7 @@ export function Sources() {
title={t("sources.empty.title")}
description={t("sources.empty.description")}
actions={
<Button onClick={() => setWizardOpen(true)}>
<Button onClick={openCreate}>
{t("sources.actions.connectSource")}
</Button>
}
@@ -86,10 +167,49 @@ export function Sources() {
<SourceDetailCard
source={expanded}
onClose={() => setExpandedId(null)}
onEdit={openEdit}
onTogglePause={togglePause}
onDelete={requestDelete}
busy={mutating}
/>
)}
<ConnectWizard open={wizardOpen} onClose={() => setWizardOpen(false)} />
<ConnectWizard
open={wizardOpen}
source={editingSource ?? undefined}
onClose={() => setWizardOpen(false)}
onCreated={refetch}
/>
<Modal
open={pendingDelete !== null}
onClose={() => !deleting && setPendingDelete(null)}
width="sm"
title={t("sources.delete.title")}
footer={
<div className="portal-sources__wizard-footer">
<Button
variant="ghost"
size="sm"
disabled={deleting}
onClick={() => setPendingDelete(null)}
>
{t("sources.delete.cancel")}
</Button>
<Button
size="sm"
accent="red"
loading={deleting}
onClick={confirmDelete}
>
{t("sources.delete.confirm")}
</Button>
</div>
}
>
<p>{t("sources.delete.body", { name: pendingDelete?.name ?? "" })}</p>
{deleteError && <Banner tone="danger" description={deleteError} />}
</Modal>
</div>
);
}
+38
View File
@@ -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",
},
});
+6 -1
View File
@@ -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,
},