merge main

This commit is contained in:
Frooodle
2026-06-27 08:23:12 +01:00
89 changed files with 3357 additions and 1437 deletions
+6
View File
@@ -41,6 +41,11 @@ jobs:
- name: Install all Playwright browsers
run: task e2e:install
- name: Build frontend (production bundle for vite preview)
env:
VITE_BUILD_FOR_PREVIEW: "1"
run: task frontend:build
- name: Run E2E tests (all browsers)
run: task e2e:cross-browser
@@ -62,4 +67,5 @@ jobs:
uses: ./.github/workflows/tauri-build.yml
with:
platform: all
sign: false
secrets: inherit
+32 -20
View File
@@ -16,6 +16,11 @@ on:
required: false
type: string
default: "all"
sign:
description: "Sign and notarize the bundles."
required: false
type: boolean
default: true
workflow_dispatch:
inputs:
platform:
@@ -28,6 +33,11 @@ on:
- windows
- macos
- linux
sign:
description: "Sign and notarize the bundles."
required: false
default: true
type: boolean
permissions:
contents: read
@@ -177,7 +187,7 @@ jobs:
# DigiCert KeyLocker Setup (Cloud HSM)
- name: Setup DigiCert KeyLocker
id: digicert-setup
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
@@ -187,7 +197,7 @@ jobs:
SM_HOST: ${{ secrets.SM_HOST }}
- name: Setup DigiCert KeyLocker Certificate
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: pwsh
run: |
Write-Host "Setting up DigiCert KeyLocker environment..."
@@ -222,7 +232,7 @@ jobs:
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
- name: Import Windows Code Signing Certificate
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
@@ -253,7 +263,7 @@ jobs:
}
- name: Import Apple Developer Certificate
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
if: inputs.sign && matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -274,7 +284,7 @@ jobs:
rm certificate.p12
- name: Verify Certificate
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
if: inputs.sign && matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
run: |
echo "Verifying Apple Developer Certificate..."
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
@@ -297,7 +307,7 @@ jobs:
ls -la /usr/bin/hd* || echo "No hd* tools found"
- name: Preflight smctl
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: pwsh
env:
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
@@ -310,7 +320,7 @@ jobs:
if ($LASTEXITCODE -ne 0) { Write-Host "[WARN] smctl windows certsync returned non-zero - continuing" }
- name: Configure Windows code signing
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: bash
env:
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
@@ -329,7 +339,7 @@ jobs:
EOF
- name: Import release GPG signing key (Linux)
if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
if: inputs.sign && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
run: |
echo "$RELEASE_GPG_PRIVATE_KEY" | gpg --batch --import
gpg --list-secret-keys --keyid-format=long
@@ -350,19 +360,21 @@ jobs:
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Apple signing/notarization env is blanked when sign is false so the
# cache warmer compiles an unsigned bundle and skips notarization.
APPLE_CERTIFICATE: ${{ inputs.sign && secrets.APPLE_CERTIFICATE || '' }}
APPLE_CERTIFICATE_PASSWORD: ${{ inputs.sign && secrets.APPLE_CERTIFICATE_PASSWORD || '' }}
APPLE_SIGNING_IDENTITY: ${{ inputs.sign && env.APPLE_SIGNING_IDENTITY || '' }}
APPLE_ID: ${{ inputs.sign && secrets.APPLE_ID || '' }}
APPLE_PASSWORD: ${{ inputs.sign && secrets.APPLE_ID_PASSWORD || '' }}
APPLE_TEAM_ID: ${{ inputs.sign && secrets.APPLE_TEAM_ID || '' }}
# AppImage signing — three env vars work together:
# SIGN=1 tells linuxdeploy-plugin-appimage to forward --sign to appimagetool
# APPIMAGETOOL_SIGN_PASSPHRASE appimagetool uses this to unlock the GPG key non-interactively
# SIGN_KEY appimagetool picks the key matching this fingerprint
# Without SIGN=1, the other two are ignored and the AppImage is built unsigned even if a key is present.
# Mirror the Windows/macOS gate: only sign when secret is present AND ref is main (skips PRs from forks/Dependabot).
SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }}
# Mirror the Windows/macOS gate: only sign when enabled AND secret is present AND ref is main (skips PRs from forks/Dependabot and the cache warmer).
SIGN: ${{ (inputs.sign && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
@@ -388,7 +400,7 @@ jobs:
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }}
SIGN: ${{ (inputs.sign && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
@@ -403,7 +415,7 @@ jobs:
args: --bundles appimage
- name: Clear release GPG key from runner keyring (Linux)
if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
if: always() && inputs.sign && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
env:
RELEASE_GPG_FINGERPRINT: ${{ vars.RELEASE_GPG_FINGERPRINT }}
run: |
@@ -413,7 +425,7 @@ jobs:
fi
- name: Verify notarization (macOS only)
if: matrix.platform == 'macos-15'
if: inputs.sign && matrix.platform == 'macos-15'
run: |
echo "🔍 Verifying notarization status..."
cd ./frontend/editor/src-tauri/target
@@ -451,7 +463,7 @@ jobs:
# Verify the MSI AND the inner exe extracted from it are signed.
# The inner exe is what gets installed on users' machines and what AV scans.
- name: Verify Windows Code Signature
if: matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main'
if: inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main'
shell: pwsh
run: |
$allSigned = $true
+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:
+39 -10
View File
@@ -295,17 +295,26 @@ tasks:
cmds:
- task: typecheck:proprietary
typecheck:_run:
internal: true
env:
CI: '{{ .CI | default "false" }}'
cmds:
- '{{ if eq .CI "true" }}npx tsc{{ else }}npx tsgo{{ end }} --noEmit --project {{.PROJECT}}'
typecheck:core:
desc: "Typecheck core build variant"
deps: [prepare]
cmds:
- npx tsc --noEmit --project editor/src/core/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/core/tsconfig.json }
typecheck:proprietary:
desc: "Typecheck proprietary build variant"
deps: [prepare]
cmds:
- npx tsc --noEmit --project editor/src/proprietary/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/proprietary/tsconfig.json }
typecheck:saas:
desc: "Typecheck SaaS build variant"
@@ -313,7 +322,8 @@ tasks:
- task: prepare
vars: { MODE: saas }
cmds:
- npx tsc --noEmit --project editor/src/saas/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/saas/tsconfig.json }
typecheck:desktop:
desc: "Typecheck desktop build variant"
@@ -321,38 +331,45 @@ tasks:
- task: prepare
vars: { MODE: desktop }
cmds:
- npx tsc --noEmit --project editor/src/desktop/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/desktop/tsconfig.json }
typecheck:cloud:
desc: "Typecheck cloud shared layer (standalone)"
deps: [prepare]
cmds:
- npx tsc --noEmit --project editor/src/cloud/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/cloud/tsconfig.json }
typecheck:scripts:
desc: "Typecheck scripts"
deps: [prepare]
cmds:
- npx tsc --noEmit --project scripts/tsconfig.json
- npx tsc --noEmit --project editor/scripts/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: scripts/tsconfig.json }
- task: typecheck:_run
vars: { PROJECT: editor/scripts/tsconfig.json }
typecheck:prototypes:
desc: "Typecheck prototypes build variant"
deps: [prepare]
cmds:
- npx tsc --noEmit --project editor/src/prototypes/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/prototypes/tsconfig.json }
typecheck:portal:
desc: "Typecheck developer portal build variant"
deps: [install]
cmds:
- npx tsc --noEmit --project portal/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: portal/tsconfig.json }
typecheck:shared:
desc: "Typecheck the shared design system"
deps: [install]
cmds:
- npx tsc --noEmit --project shared/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: shared/tsconfig.json }
typecheck:all:
desc: "Typecheck all build variants"
@@ -404,10 +421,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);
+2 -1
View File
@@ -51,7 +51,8 @@
"desktopTemplate": "stirling-pdf.desktop"
},
"rpm": {
"desktopTemplate": "stirling-pdf.desktop"
"desktopTemplate": "stirling-pdf.desktop",
"compression": { "type": "zstd", "level": 3 }
},
"appimage": {
"bundleMediaFramework": false
+199 -57
View File
@@ -109,6 +109,7 @@
"@types/react-dom": "^19.1.9",
"@typescript-eslint/eslint-plugin": "^8.61.1",
"@typescript-eslint/parser": "^8.61.1",
"@typescript/native-preview": "7.0.0-dev.20260624.1",
"@vitejs/plugin-react-swc": "^4.1.0",
"@vitest/coverage-v8": "^3.2.4",
"dotenv": "^16.4.7",
@@ -6028,6 +6029,147 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@typescript/native-preview": {
"version": "7.0.0-dev.20260624.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260624.1.tgz",
"integrity": "sha512-ogwfNo1xuAutOF8RbTCo3Ut0q/65u2ucOeHizi6O14q+3vnelNS+u8qVC2QWXubMcwtuN5E9cbfPslvGC4kdwA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsgo": "bin/tsgo.js"
},
"engines": {
"node": ">=16.20.0"
},
"optionalDependencies": {
"@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260624.1",
"@typescript/native-preview-darwin-x64": "7.0.0-dev.20260624.1",
"@typescript/native-preview-linux-arm": "7.0.0-dev.20260624.1",
"@typescript/native-preview-linux-arm64": "7.0.0-dev.20260624.1",
"@typescript/native-preview-linux-x64": "7.0.0-dev.20260624.1",
"@typescript/native-preview-win32-arm64": "7.0.0-dev.20260624.1",
"@typescript/native-preview-win32-x64": "7.0.0-dev.20260624.1"
}
},
"node_modules/@typescript/native-preview-darwin-arm64": {
"version": "7.0.0-dev.20260624.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260624.1.tgz",
"integrity": "sha512-g8CqDkYCHTCYdhBHXs5cMraBurOS+KrcMFxE0SsaKZoI6Tnp+le1aWvxUBbzNKJYyThHJqb/1mLopzEJxJCuKA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/native-preview-darwin-x64": {
"version": "7.0.0-dev.20260624.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260624.1.tgz",
"integrity": "sha512-P00JVvSV90eioYDuINAKmOSA8yhFTWLq6RvS5lrCfUuDlcgr2kSOgZAfFHIksHBVz6ZXpAXpa0dHPmc5SJ3Ymw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/native-preview-linux-arm": {
"version": "7.0.0-dev.20260624.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260624.1.tgz",
"integrity": "sha512-eWHELvfQMkVRjafMd+3ATgM9p9yAergJaM4AOY8AekCNWnHFwUrp/ohh+ryyMUIqque5jjb/kuTiOiGj728I2Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/native-preview-linux-arm64": {
"version": "7.0.0-dev.20260624.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260624.1.tgz",
"integrity": "sha512-cppM2yTZ/Gd1hOXy8NEJcUBxJ0O0zl9CU3OU1ZWZ/OHWWX/ukEzCCr94SUwJhjIWOylBCpIYkrvYoTwxNa94XQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/native-preview-linux-x64": {
"version": "7.0.0-dev.20260624.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260624.1.tgz",
"integrity": "sha512-FaB8rS+rKYz4nDrEsHsF3b4cn7eCKCYroMJReA375OuQ6PHcmCNQ6QlVetA0dfFBxTTgejmoKyfw9xgAA5P4Yw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/native-preview-win32-arm64": {
"version": "7.0.0-dev.20260624.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260624.1.tgz",
"integrity": "sha512-BgkqbCmSHDb5UxqWaFlFFJ/DHNT3lEUO4W8627ap6+QthJZuXk2imiHAX3PgYXC6en9fLLyR6jjcseAa4CCshg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/native-preview-win32-x64": {
"version": "7.0.0-dev.20260624.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260624.1.tgz",
"integrity": "sha512-WaZ+ue63NgB2j/lqjirfevh/TqcsCxSqnKhGGiRnlxHyYIBcoq+x7KngyEnyGIaywJE1PcFeXA+2EMSIPlSEiQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@@ -6207,14 +6349,14 @@
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.38.tgz",
"integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==",
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz",
"integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.29.7",
"@vue/shared": "3.5.38",
"@vue/shared": "3.5.39",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
@@ -6241,28 +6383,28 @@
"peer": true
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz",
"integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==",
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz",
"integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-core": "3.5.38",
"@vue/shared": "3.5.38"
"@vue/compiler-core": "3.5.39",
"@vue/shared": "3.5.39"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz",
"integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==",
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz",
"integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.29.7",
"@vue/compiler-core": "3.5.38",
"@vue/compiler-dom": "3.5.38",
"@vue/compiler-ssr": "3.5.38",
"@vue/shared": "3.5.38",
"@vue/compiler-core": "3.5.39",
"@vue/compiler-dom": "3.5.39",
"@vue/compiler-ssr": "3.5.39",
"@vue/shared": "3.5.39",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.15",
@@ -6277,68 +6419,68 @@
"peer": true
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz",
"integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==",
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz",
"integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.38",
"@vue/shared": "3.5.38"
"@vue/compiler-dom": "3.5.39",
"@vue/shared": "3.5.39"
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.38.tgz",
"integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==",
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz",
"integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.38"
"@vue/shared": "3.5.39"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.38.tgz",
"integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==",
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz",
"integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.38",
"@vue/shared": "3.5.38"
"@vue/reactivity": "3.5.39",
"@vue/shared": "3.5.39"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz",
"integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==",
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz",
"integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.38",
"@vue/runtime-core": "3.5.38",
"@vue/shared": "3.5.38",
"@vue/reactivity": "3.5.39",
"@vue/runtime-core": "3.5.39",
"@vue/shared": "3.5.39",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.38.tgz",
"integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==",
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz",
"integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.38",
"@vue/shared": "3.5.38"
"@vue/compiler-ssr": "3.5.39",
"@vue/shared": "3.5.39"
},
"peerDependencies": {
"vue": "3.5.38"
"vue": "3.5.39"
}
},
"node_modules/@vue/shared": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.38.tgz",
"integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==",
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz",
"integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==",
"license": "MIT",
"peer": true
},
@@ -15611,9 +15753,9 @@
}
},
"node_modules/svelte": {
"version": "5.56.3",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.3.tgz",
"integrity": "sha512-w7JvrM5IFl5cmfbY0TLik9o7mjRUJmRMhOR51tBPu708Gr/MjbGs7VnJnr/B0CaXeI4vtnOh7RKxDr0cwhMdDA==",
"version": "5.56.4",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz",
"integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -15628,7 +15770,7 @@
"clsx": "^2.1.1",
"devalue": "^5.8.1",
"esm-env": "^1.2.1",
"esrap": "^2.2.11",
"esrap": "^2.2.12",
"is-reference": "^3.0.3",
"locate-character": "^3.0.0",
"magic-string": "^0.30.11",
@@ -16898,17 +17040,17 @@
}
},
"node_modules/vue": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz",
"integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==",
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz",
"integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.38",
"@vue/compiler-sfc": "3.5.38",
"@vue/runtime-dom": "3.5.38",
"@vue/server-renderer": "3.5.38",
"@vue/shared": "3.5.38"
"@vue/compiler-dom": "3.5.39",
"@vue/compiler-sfc": "3.5.39",
"@vue/runtime-dom": "3.5.39",
"@vue/server-renderer": "3.5.39",
"@vue/shared": "3.5.39"
},
"peerDependencies": {
"typescript": "*"
+1
View File
@@ -129,6 +129,7 @@
"@types/react-dom": "^19.1.9",
"@typescript-eslint/eslint-plugin": "^8.61.1",
"@typescript-eslint/parser": "^8.61.1",
"@typescript/native-preview": "7.0.0-dev.20260624.1",
"@vitejs/plugin-react-swc": "^4.1.0",
"@vitest/coverage-v8": "^3.2.4",
"dotenv": "^16.4.7",
@@ -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,
},