mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
Redesign S3 connections to use connection resolver (#6965)
# Description of Changes Redesign S3 connections based on feedback from #6948. Also redesigns the UI for Sources to make them more like the Pipelines page which improves UX quite a bit. There's still plenty more UI/UX work for Sources and S3 but moving in the right direction.
This commit is contained in:
+40
-4
@@ -43,6 +43,10 @@ public class IntegrationConfigService {
|
||||
private final OwnershipService ownership;
|
||||
private final SecretMasker secretMasker;
|
||||
private final ResourceGrantRepository grantRepository;
|
||||
// Bean-discovered extension points: features that understand a type contribute its config
|
||||
// schema and report what still references a config, without this module depending on them.
|
||||
private final List<IntegrationConfigValidator> validators;
|
||||
private final List<IntegrationConfigUsageCheck> usageChecks;
|
||||
|
||||
// ---- commands ----
|
||||
|
||||
@@ -66,13 +70,21 @@ public class IntegrationConfigService {
|
||||
? DefaultAccessPolicy.EXPLICIT_ONLY
|
||||
: request.defaultAccess());
|
||||
|
||||
// TEAM scope may omit the team id: default to the caller's own team so clients (the
|
||||
// portal) need not know it. assignOwnership still enforces admin-or-leader of that team.
|
||||
Long ownerTeamId = request.ownerTeamId();
|
||||
if (ownerTeamId == null && scope == OwnerScope.TEAM && currentUser.getTeam() != null) {
|
||||
ownerTeamId = currentUser.getTeam().getId();
|
||||
}
|
||||
ownership.assignOwnership(
|
||||
cfg,
|
||||
scope,
|
||||
request.ownerTeamId(),
|
||||
ownerTeamId,
|
||||
currentUser,
|
||||
() -> lockedServerExists(cfg.getIntegrationType()));
|
||||
cfg.setConfig(writeJson(secretMasker.sanitize(request.config())));
|
||||
Map<String, Object> config = secretMasker.sanitize(request.config());
|
||||
validateConfig(cfg.getIntegrationType(), config);
|
||||
cfg.setConfig(writeJson(config));
|
||||
return repository.save(cfg);
|
||||
}
|
||||
|
||||
@@ -101,8 +113,10 @@ public class IntegrationConfigService {
|
||||
cfg.setDefaultAccess(request.defaultAccess());
|
||||
}
|
||||
if (request.config() != null) {
|
||||
cfg.setConfig(
|
||||
writeJson(secretMasker.merge(readJson(cfg.getConfig()), request.config())));
|
||||
Map<String, Object> merged =
|
||||
secretMasker.merge(readJson(cfg.getConfig()), request.config());
|
||||
validateConfig(cfg.getIntegrationType(), merged);
|
||||
cfg.setConfig(writeJson(merged));
|
||||
}
|
||||
return repository.save(cfg);
|
||||
}
|
||||
@@ -113,6 +127,15 @@ public class IntegrationConfigService {
|
||||
if (!ownership.canManage(TYPE, cfg, currentUser)) {
|
||||
throw forbidden("You cannot manage this integration");
|
||||
}
|
||||
// Refuse to pull a connection out from under whatever still references it.
|
||||
List<String> usages =
|
||||
usageChecks.stream()
|
||||
.flatMap(check -> check.usagesOf(cfg.getId()).stream())
|
||||
.toList();
|
||||
if (!usages.isEmpty()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.CONFLICT, "Integration is in use by: " + String.join(", ", usages));
|
||||
}
|
||||
// Drop grants sharing this config so they do not dangle as dead rows.
|
||||
grantRepository.deleteByResourceTypeAndResourceId(TYPE, String.valueOf(cfg.getId()));
|
||||
repository.delete(cfg);
|
||||
@@ -188,6 +211,19 @@ public class IntegrationConfigService {
|
||||
|
||||
// ---- integration-specific glue ----
|
||||
|
||||
/** Runs every registered validator for the type; unknown types save free-form. */
|
||||
private void validateConfig(IntegrationType type, Map<String, Object> config) {
|
||||
for (IntegrationConfigValidator validator : validators) {
|
||||
if (validator.type() == type) {
|
||||
try {
|
||||
validator.validate(config == null ? Map.of() : config);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A non-admin can't create a personal config of a type an admin has locked at server scope. */
|
||||
private boolean lockedServerExists(IntegrationType type) {
|
||||
return repository.findByScope(OwnerScope.SERVER).stream()
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package stirling.software.proprietary.integration.service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Reports what still references an integration config, so deletion can be refused instead of
|
||||
* pulling a connection out from under a live consumer. Implementations are beans discovered by
|
||||
* {@link IntegrationConfigService} (e.g. the policy subsystem reporting sources and pipelines that
|
||||
* reference a connection).
|
||||
*/
|
||||
public interface IntegrationConfigUsageCheck {
|
||||
|
||||
/** Human-readable labels of everything still using the config; empty when unreferenced. */
|
||||
List<String> usagesOf(long configId);
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package stirling.software.proprietary.integration.service;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
|
||||
/**
|
||||
* Validates one integration type's config map at save time. Implementations are beans discovered by
|
||||
* {@link IntegrationConfigService}, so the feature that understands a type (e.g. the policy S3
|
||||
* backend) owns its schema without the integration module depending on it. Types with no registered
|
||||
* validator save free-form.
|
||||
*/
|
||||
public interface IntegrationConfigValidator {
|
||||
|
||||
/** The type this validator understands. */
|
||||
IntegrationType type();
|
||||
|
||||
/**
|
||||
* Validates the config as it will be stored (secrets already sanitized/merged, so values are
|
||||
* real, never the redaction mask). Throws {@link IllegalArgumentException} on bad config.
|
||||
*/
|
||||
void validate(Map<String, Object> config);
|
||||
}
|
||||
+20
@@ -120,6 +120,7 @@ public class PolicyController {
|
||||
throws IOException {
|
||||
stampPolicyAudit(definition);
|
||||
requireRunnable(definition);
|
||||
validateAdHocOutput(definition);
|
||||
PolicyInputs inputs = toInputs(files);
|
||||
PolicyRunHandle handle =
|
||||
policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP);
|
||||
@@ -140,6 +141,7 @@ public class PolicyController {
|
||||
throws IOException {
|
||||
stampPolicyAudit(definition);
|
||||
requireRunnable(definition);
|
||||
validateAdHocOutput(definition);
|
||||
PolicyInputs inputs = toInputs(files);
|
||||
|
||||
SseEmitter emitter =
|
||||
@@ -530,6 +532,24 @@ public class PolicyController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization-check an ad-hoc run's output while the caller's principal is present (this
|
||||
* request thread). The worker thread that later delivers carries no security context, so an S3
|
||||
* output's connection-access check would be skipped there; without this gate a caller could
|
||||
* reference another tenant's connection by id and write to it (confused deputy). Stored
|
||||
* policies are covered by save-time {@link PolicyValidator#validate} instead.
|
||||
*/
|
||||
private void validateAdHocOutput(PipelineDefinition definition) {
|
||||
if (definition.output() == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
policyValidator.validateOutput(definition.output());
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ad-hoc runs (AI / one-off pipelines) are still editor activity, so their supplied documents
|
||||
* feed the same virtual editor source as stored editor policies, counted against the caller's
|
||||
|
||||
+13
-1
@@ -52,7 +52,19 @@ public class PolicyValidator {
|
||||
InputSpec spec = source.toInputSpec();
|
||||
inputSourceFor(spec).validate(spec);
|
||||
}
|
||||
outputSinkFor(policy.output()).validate(policy.output());
|
||||
validateOutput(policy.output());
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an output spec against its sink. Must be called on a request thread (caller's
|
||||
* principal present) so an S3 output's connection is authorization-checked against the caller -
|
||||
* ad-hoc runs are never persisted and so never hit {@link #validate(Policy)}, and the worker
|
||||
* thread that later delivers has no principal, so this is their only access gate.
|
||||
*
|
||||
* @throws IllegalArgumentException if the type is unknown or the config is invalid/inaccessible
|
||||
*/
|
||||
public void validateOutput(OutputSpec output) {
|
||||
outputSinkFor(output).validate(output);
|
||||
}
|
||||
|
||||
private PolicyTrigger triggerFor(TriggerConfig config) {
|
||||
|
||||
+14
-13
@@ -18,6 +18,7 @@ import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||
import stirling.software.proprietary.policy.s3.S3Config;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionResolver;
|
||||
import stirling.software.proprietary.policy.s3.S3Identities;
|
||||
|
||||
import software.amazon.awssdk.core.exception.SdkException;
|
||||
@@ -36,15 +37,14 @@ import software.amazon.awssdk.services.s3.model.S3Object;
|
||||
* Reads input files from an Amazon S3 (or S3-compatible) bucket; each listed object is its own unit
|
||||
* of work, claimed through the {@link ResolveContext} ledger and tracked in place. Identity and
|
||||
* version gate come from {@link S3Identities}, so the steady-state sweep never downloads content.
|
||||
* Options (see {@link S3Config}): "bucket" (required), "region" (default us-east-1), "prefix" (only
|
||||
* keys starting with it are read), "endpoint" (S3-compatible stores such as MinIO; path-style
|
||||
* addressing is used automatically), "accessKeyId" and "secretAccessKey" (required; requests are
|
||||
* never signed with the server's own AWS identity), and "mode" which is "consume" (default: a
|
||||
* processed object is deleted once every policy that claimed it has settled successfully and it is
|
||||
* still the version that ran; failures stay in place and are not retried until they change) or
|
||||
* "snapshot" (stateless, every run sees the full set). Keys ending in "/" (folder placeholders) and
|
||||
* keys with a dot-prefixed path segment are never picked up, mirroring the folder source's
|
||||
* hidden-file rule.
|
||||
* Options: "connectionId" references the stored S3 connection (an {@code IntegrationConfig} owning
|
||||
* bucket, region, endpoint, and credentials - resolved by {@link S3ConnectionResolver}); "prefix"
|
||||
* (only keys starting with it are read) and "mode" are per-source, where mode is "consume"
|
||||
* (default: a processed object is deleted once every policy that claimed it has settled
|
||||
* successfully and it is still the version that ran; failures stay in place and are not retried
|
||||
* until they change) or "snapshot" (stateless, every run sees the full set). Keys ending in "/"
|
||||
* (folder placeholders) and keys with a dot-prefixed path segment are never picked up, mirroring
|
||||
* the folder source's hidden-file rule.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -55,6 +55,7 @@ public class S3InputSource implements InputSource {
|
||||
private static final String TYPE = "s3";
|
||||
|
||||
private final S3ConnectionPool connectionPool;
|
||||
private final S3ConnectionResolver connectionResolver;
|
||||
|
||||
@Override
|
||||
public String type() {
|
||||
@@ -67,12 +68,12 @@ public class S3InputSource implements InputSource {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails fast at save time: bad config shape, a private endpoint without the operator opt-in, or
|
||||
* a bucket the supplied credentials cannot list.
|
||||
* Fails fast at save time: an unknown/disabled/unusable connection, bad config shape, a private
|
||||
* endpoint without the operator opt-in, or a bucket the connection cannot list.
|
||||
*/
|
||||
@Override
|
||||
public void validate(InputSpec spec) {
|
||||
S3Config config = S3Config.from(spec.options());
|
||||
S3Config config = connectionResolver.resolve(spec.options());
|
||||
try {
|
||||
connectionPool.clientFor(config).listObjectsV2(listRequest(config).maxKeys(1).build());
|
||||
} catch (SdkException e) {
|
||||
@@ -89,7 +90,7 @@ public class S3InputSource implements InputSource {
|
||||
|
||||
@Override
|
||||
public List<ResolvedInput> resolve(InputSpec spec, ResolveContext ctx) throws IOException {
|
||||
S3Config config = S3Config.from(spec.options());
|
||||
S3Config config = connectionResolver.resolve(spec.options());
|
||||
S3Client client = connectionPool.clientFor(config);
|
||||
// A listing failure propagates so the sweep reads it as "could not list" (which vetoes
|
||||
// presence cleanup), never as "verifiably no objects".
|
||||
|
||||
+7
-5
@@ -27,6 +27,7 @@ import stirling.software.proprietary.policy.ledger.ProcessedLedger;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.s3.S3Config;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionResolver;
|
||||
import stirling.software.proprietary.policy.s3.S3Identities;
|
||||
|
||||
import software.amazon.awssdk.core.exception.SdkException;
|
||||
@@ -59,6 +60,7 @@ public class S3OutputSink implements PolicyOutputSink {
|
||||
private static final String TYPE = "s3";
|
||||
|
||||
private final S3ConnectionPool connectionPool;
|
||||
private final S3ConnectionResolver connectionResolver;
|
||||
private final ProcessedLedger processedLedger;
|
||||
|
||||
@Override
|
||||
@@ -72,19 +74,19 @@ public class S3OutputSink implements PolicyOutputSink {
|
||||
}
|
||||
|
||||
/**
|
||||
* Config shape and endpoint guard only - no network probe, since write-only credentials
|
||||
* (s3:PutObject without s3:ListBucket) are a legitimate setup for an output bucket and a
|
||||
* listing probe would wrongly reject them.
|
||||
* Connection resolution (including the saving user's right to use it) and endpoint guard only -
|
||||
* no network probe, since write-only credentials (s3:PutObject without s3:ListBucket) are a
|
||||
* legitimate setup for an output bucket and a listing probe would wrongly reject them.
|
||||
*/
|
||||
@Override
|
||||
public void validate(OutputSpec spec) {
|
||||
connectionPool.clientFor(S3Config.from(spec.options()));
|
||||
connectionPool.clientFor(connectionResolver.resolve(spec.options()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ResultFile> deliver(
|
||||
OutputDelivery delivery, List<Resource> outputs, OutputSpec spec) throws IOException {
|
||||
S3Config config = S3Config.from(spec.options());
|
||||
S3Config config = connectionResolver.resolve(spec.options());
|
||||
S3Client client = connectionPool.clientFor(config);
|
||||
|
||||
List<ResultFile> results = new ArrayList<>();
|
||||
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.access.model.DefaultAccessPolicy;
|
||||
import stirling.software.proprietary.access.model.OwnerScope;
|
||||
import stirling.software.proprietary.integration.model.IntegrationConfig;
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
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;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* One-time, idempotent extraction of legacy embedded S3 credentials into stored connections:
|
||||
* sources and policy outputs written before connections shipped carry bucket/credentials in their
|
||||
* own options; this rewrites each to reference a (deduplicated) S3 {@link IntegrationConfig} and
|
||||
* keeps only per-use options (prefix, mode). MUST be programmatic - the option JSON is encrypted at
|
||||
* the application layer, so no SQL migration can read it.
|
||||
*
|
||||
* <p>Idempotent by construction: rewritten rows no longer embed credentials, so re-runs find
|
||||
* nothing to do. Connections are deduplicated against both this run's extractions and existing S3
|
||||
* connections; a concurrent multi-node boot can at worst create a redundant connection row, never
|
||||
* corrupt a source. Ownership follows the owning row: team-scoped when the source/policy has a
|
||||
* team, server-scoped otherwise (single-operator self-hosted).
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class EmbeddedS3CredentialMigration {
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
private static final List<String> CONNECTION_OPTIONS =
|
||||
List.of("bucket", "region", "endpoint", "accessKeyId", "secretAccessKey");
|
||||
// Field separator for the dedup key: a unit-separator control char that cannot appear in a
|
||||
// bucket/region/endpoint/credential, so distinct field sets can never collide.
|
||||
private static final char DELIMITER = '\u001f';
|
||||
|
||||
private final SourceStore sourceStore;
|
||||
private final PolicyStore policyStore;
|
||||
private final IntegrationConfigRepository connections;
|
||||
private final TeamRepository teamRepository;
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
@Transactional
|
||||
public void migrate() {
|
||||
Map<String, IntegrationConfig> byCredentialKey = indexExistingConnections();
|
||||
int migrated = 0;
|
||||
for (Source source : sourceStore.all()) {
|
||||
if (!"s3".equals(source.type()) || !embedsCredentials(source.options())) {
|
||||
continue;
|
||||
}
|
||||
IntegrationConfig connection =
|
||||
connectionFor(source.options(), source.teamId(), byCredentialKey);
|
||||
sourceStore.save(withOptions(source, referencing(connection, source.options(), true)));
|
||||
migrated++;
|
||||
}
|
||||
for (Policy policy : policyStore.all()) {
|
||||
OutputSpec output = policy.output();
|
||||
if (!"s3".equals(output.type()) || !embedsCredentials(output.options())) {
|
||||
continue;
|
||||
}
|
||||
IntegrationConfig connection =
|
||||
connectionFor(output.options(), policy.teamId(), byCredentialKey);
|
||||
policyStore.save(
|
||||
withOutput(
|
||||
policy,
|
||||
new OutputSpec(
|
||||
output.type(),
|
||||
referencing(connection, output.options(), false))));
|
||||
migrated++;
|
||||
}
|
||||
if (migrated > 0) {
|
||||
log.info("Extracted embedded S3 credentials from {} row(s) into connections", migrated);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean embedsCredentials(Map<String, Object> options) {
|
||||
return options.get("accessKeyId") != null;
|
||||
}
|
||||
|
||||
/** Reuses an existing connection with identical coordinates+credentials, else creates one. */
|
||||
private IntegrationConfig connectionFor(
|
||||
Map<String, Object> options, Long teamId, Map<String, IntegrationConfig> byKey) {
|
||||
String key = credentialKey(options);
|
||||
IntegrationConfig existing = byKey.get(key);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
IntegrationConfig connection = new IntegrationConfig();
|
||||
connection.setIntegrationType(IntegrationType.S3);
|
||||
connection.setName(connectionName(options, byKey));
|
||||
connection.setEnabled(true);
|
||||
connection.setLocked(false);
|
||||
connection.setDefaultAccess(DefaultAccessPolicy.EXPLICIT_ONLY);
|
||||
Team team = teamId == null ? null : teamRepository.findById(teamId).orElse(null);
|
||||
if (team != null) {
|
||||
connection.setScope(OwnerScope.TEAM);
|
||||
connection.setOwnerTeam(team);
|
||||
} else {
|
||||
// No team (teamless self-hosted, or a source whose team was since deleted): server
|
||||
// scope, i.e. admin-owned. An orphaned-team source's non-admin editor would then need
|
||||
// an admin to re-share the connection - acceptable for the narrow orphaned case.
|
||||
connection.setScope(OwnerScope.SERVER);
|
||||
}
|
||||
Map<String, Object> config = new LinkedHashMap<>();
|
||||
for (String option : CONNECTION_OPTIONS) {
|
||||
Object value = options.get(option);
|
||||
if (value != null && !value.toString().isBlank()) {
|
||||
config.put(option, value);
|
||||
}
|
||||
}
|
||||
connection.setConfig(OBJECT_MAPPER.writeValueAsString(config));
|
||||
IntegrationConfig saved = connections.save(connection);
|
||||
byKey.put(key, saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** The rewritten options: the connection reference plus per-use settings only. */
|
||||
private static Map<String, Object> referencing(
|
||||
IntegrationConfig connection, Map<String, Object> legacy, boolean keepMode) {
|
||||
Map<String, Object> options = new LinkedHashMap<>();
|
||||
options.put(S3ConnectionResolver.CONNECTION_ID_OPTION, connection.getId());
|
||||
Object prefix = legacy.get("prefix");
|
||||
if (prefix != null && !prefix.toString().isBlank()) {
|
||||
options.put("prefix", prefix);
|
||||
}
|
||||
Object mode = legacy.get("mode");
|
||||
if (keepMode && mode != null && !mode.toString().isBlank()) {
|
||||
options.put("mode", mode);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
private Map<String, IntegrationConfig> indexExistingConnections() {
|
||||
Map<String, IntegrationConfig> byKey = new LinkedHashMap<>();
|
||||
for (IntegrationConfig connection : connections.findAll()) {
|
||||
if (connection.getIntegrationType() != IntegrationType.S3) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
Map<String, Object> config =
|
||||
OBJECT_MAPPER.readValue(connection.getConfig(), Map.class);
|
||||
byKey.putIfAbsent(credentialKey(config), connection);
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Skipping unreadable S3 connection {} while indexing: {}",
|
||||
connection.getId(),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
return byKey;
|
||||
}
|
||||
|
||||
private static String credentialKey(Map<String, Object> options) {
|
||||
StringBuilder key = new StringBuilder();
|
||||
for (String option : CONNECTION_OPTIONS) {
|
||||
Object value = options.get(option);
|
||||
key.append(value == null ? "" : value.toString().trim()).append(DELIMITER);
|
||||
}
|
||||
return key.toString();
|
||||
}
|
||||
|
||||
private static String connectionName(
|
||||
Map<String, Object> options, Map<String, IntegrationConfig> byKey) {
|
||||
String base = "S3: " + options.getOrDefault("bucket", "bucket");
|
||||
long sameName = byKey.values().stream().filter(c -> c.getName().startsWith(base)).count();
|
||||
return sameName == 0 ? base : base + " (" + (sameName + 1) + ")";
|
||||
}
|
||||
|
||||
private static Source withOptions(Source source, Map<String, Object> options) {
|
||||
return new Source(
|
||||
source.id(),
|
||||
source.name(),
|
||||
source.type(),
|
||||
options,
|
||||
source.enabled(),
|
||||
source.owner(),
|
||||
source.teamId());
|
||||
}
|
||||
|
||||
private static Policy withOutput(Policy policy, OutputSpec output) {
|
||||
return new Policy(
|
||||
policy.id(),
|
||||
policy.name(),
|
||||
policy.owner(),
|
||||
policy.enabled(),
|
||||
policy.trigger(),
|
||||
policy.sourceIds(),
|
||||
policy.steps(),
|
||||
output,
|
||||
policy.teamId());
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.integration.service.IntegrationConfigUsageCheck;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Reports the policy sources and pipeline outputs referencing an S3 connection, so the connection
|
||||
* cannot be deleted out from under them (mirrors {@code SourceController}'s referenced-source
|
||||
* delete guard). Scans in memory - fine at admin-dashboard scale, always consistent with the live
|
||||
* stores.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class PolicyS3ConnectionUsageCheck implements IntegrationConfigUsageCheck {
|
||||
|
||||
private final SourceStore sourceStore;
|
||||
private final PolicyStore policyStore;
|
||||
|
||||
@Override
|
||||
public List<String> usagesOf(long configId) {
|
||||
List<String> usages = new ArrayList<>();
|
||||
for (Source source : sourceStore.all()) {
|
||||
if (references(source.options(), configId)) {
|
||||
usages.add("source '" + source.name() + "'");
|
||||
}
|
||||
}
|
||||
for (Policy policy : policyStore.all()) {
|
||||
if (references(policy.output().options(), configId)) {
|
||||
usages.add("pipeline '" + policy.name() + "'");
|
||||
}
|
||||
}
|
||||
return usages;
|
||||
}
|
||||
|
||||
private static boolean references(Map<String, Object> options, long configId) {
|
||||
try {
|
||||
Long reference = S3ConnectionResolver.connectionId(options);
|
||||
return reference != null && reference == configId;
|
||||
} catch (IllegalArgumentException unparseable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,12 @@ import java.net.URISyntaxException;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Connection settings shared by the S3 input source and output sink, parsed from a spec's options
|
||||
* map. Credentials are required: there is deliberately no fallback to the server's own AWS
|
||||
* credential chain, so user-supplied config can never borrow the host's identity. {@code snapshot}
|
||||
* is input-only and ignored by the sink.
|
||||
* The fully resolved connection settings the S3 input source and output sink run with - normally
|
||||
* produced by {@link S3ConnectionResolver} merging a stored connection (bucket, region, endpoint,
|
||||
* credentials) with per-use options (prefix, mode), or parsed directly from legacy options that
|
||||
* still embed credentials. Credentials are required: there is deliberately no fallback to the
|
||||
* server's own AWS credential chain, so user-supplied config can never borrow the host's identity.
|
||||
* {@code snapshot} is input-only and ignored by the sink.
|
||||
*/
|
||||
public record S3Config(
|
||||
String bucket,
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.access.model.ResourceType;
|
||||
import stirling.software.proprietary.access.service.OwnershipService;
|
||||
import stirling.software.proprietary.integration.model.IntegrationConfig;
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Turns a source's or output's options into a full {@link S3Config} by dereferencing its {@code
|
||||
* connectionId} to a stored S3 {@link IntegrationConfig} (the connection owns bucket, region,
|
||||
* endpoint, and credentials; the options own per-use settings such as prefix and mode). Options
|
||||
* with no {@code connectionId} fall back to legacy embedded credentials, so rows written before
|
||||
* connections shipped keep working until {@link EmbeddedS3CredentialMigration} rewrites them.
|
||||
*
|
||||
* <p>When an authenticated caller is present (save-time validation), they must be allowed to use
|
||||
* the connection. Background sweeps and deliveries run with no caller and skip that check: the
|
||||
* referencing source or policy was access-checked when it was saved.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class S3ConnectionResolver {
|
||||
|
||||
static final String CONNECTION_ID_OPTION = "connectionId";
|
||||
private static final String PREFIX_OPTION = "prefix";
|
||||
private static final String MODE_OPTION = "mode";
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final IntegrationConfigRepository connections;
|
||||
private final OwnershipService ownership;
|
||||
private final UserService userService;
|
||||
|
||||
public S3Config resolve(Map<String, Object> options) {
|
||||
Long connectionId = connectionId(options);
|
||||
if (connectionId == null) {
|
||||
// Legacy embedded credentials, pending migration.
|
||||
return S3Config.from(options);
|
||||
}
|
||||
IntegrationConfig connection =
|
||||
connections
|
||||
.findById(connectionId)
|
||||
.filter(cfg -> cfg.getIntegrationType() == IntegrationType.S3)
|
||||
.filter(this::usableByCurrentUser)
|
||||
// Existence and access collapse into one error: a caller must not be able
|
||||
// to tell "no such connection" from "someone else's connection" and
|
||||
// enumerate ids. The id/name are never echoed.
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalArgumentException(
|
||||
"unknown or inaccessible s3 connection"));
|
||||
if (!connection.isEnabled()) {
|
||||
throw new IllegalArgumentException("s3 connection is disabled");
|
||||
}
|
||||
Map<String, Object> merged = new LinkedHashMap<>(connectionConfig(connection));
|
||||
copyPerUseOption(options, merged, PREFIX_OPTION);
|
||||
copyPerUseOption(options, merged, MODE_OPTION);
|
||||
return S3Config.from(merged);
|
||||
}
|
||||
|
||||
/** The {@code connectionId} option as a long, or null when the options are legacy-embedded. */
|
||||
static Long connectionId(Map<String, Object> options) {
|
||||
Object reference = options.get(CONNECTION_ID_OPTION);
|
||||
if (reference == null || (reference instanceof String s && s.isBlank())) {
|
||||
return null;
|
||||
}
|
||||
if (reference instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(reference.toString().trim());
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException(
|
||||
"s3 'connectionId' is not a valid connection reference: " + reference);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current caller may use this connection. With no principal - a background sweep or
|
||||
* delivery on a worker thread that carries no {@code SecurityContext} - access is treated as
|
||||
* already established: stored policies are validated with the caller present at save time, and
|
||||
* ad-hoc runs are validated on the request thread before dispatch (see {@code
|
||||
* PolicyValidator#validateOutput}). A missing principal must therefore never be the ONLY thing
|
||||
* standing between a caller and a connection, or the check becomes a confused deputy.
|
||||
*/
|
||||
private boolean usableByCurrentUser(IntegrationConfig connection) {
|
||||
User user = currentUser();
|
||||
return user == null || ownership.canUse(ResourceType.INTEGRATION_CONFIG, connection, user);
|
||||
}
|
||||
|
||||
// Mirrors ResourceAccessSecurity's principal resolution; null when unauthenticated.
|
||||
private User currentUser() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
return null;
|
||||
}
|
||||
Object principal = auth.getPrincipal();
|
||||
if (principal instanceof User user) {
|
||||
return user;
|
||||
}
|
||||
if (principal instanceof UserDetails userDetails) {
|
||||
return userService.findByUsername(userDetails.getUsername()).orElse(null);
|
||||
}
|
||||
if (principal instanceof String username && !"anonymousUser".equals(username)) {
|
||||
return userService.findByUsername(username).orElse(null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Map<String, Object> connectionConfig(IntegrationConfig connection) {
|
||||
String json = connection.getConfig();
|
||||
if (json == null || json.isBlank()) {
|
||||
return Map.of();
|
||||
}
|
||||
try {
|
||||
return OBJECT_MAPPER.readValue(
|
||||
json, new TypeReference<LinkedHashMap<String, Object>>() {});
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException(
|
||||
"s3 connection '" + connection.getName() + "' has unreadable config", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void copyPerUseOption(
|
||||
Map<String, Object> options, Map<String, Object> merged, String key) {
|
||||
Object value = options.get(key);
|
||||
if (value != null && !value.toString().isBlank()) {
|
||||
merged.put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.cluster.s3.S3Clients;
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
import stirling.software.proprietary.integration.service.IntegrationConfigValidator;
|
||||
|
||||
/**
|
||||
* The S3 connection schema, enforced when an S3 {@link IntegrationType} config is saved: bucket and
|
||||
* credentials required, endpoint an http(s) URL that must not reach private addresses without the
|
||||
* operator opt-in - the same rules {@link S3ConnectionPool} enforces before signing, moved to save
|
||||
* time so a bad connection fails in the form rather than in a sweep.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class S3IntegrationValidator implements IntegrationConfigValidator {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@Override
|
||||
public IntegrationType type() {
|
||||
return IntegrationType.S3;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate(Map<String, Object> config) {
|
||||
S3Config parsed = S3Config.from(config);
|
||||
if (parsed.endpoint() == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
S3Clients.validateEndpointHost(
|
||||
URI.create(parsed.endpoint()),
|
||||
applicationProperties.getPolicies().isAllowPrivateS3Endpoints(),
|
||||
"S3 connection endpoint",
|
||||
"set policies.allowPrivateS3Endpoints=true to opt in (e.g. for a local"
|
||||
+ " MinIO).");
|
||||
} catch (IllegalStateException e) {
|
||||
throw new IllegalArgumentException(e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
-2
@@ -13,9 +13,9 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -52,7 +52,58 @@ class IntegrationConfigServiceTest {
|
||||
@Mock
|
||||
private stirling.software.proprietary.access.repository.ResourceGrantRepository grantRepository;
|
||||
|
||||
@InjectMocks private IntegrationConfigService service;
|
||||
@Mock private IntegrationConfigValidator validator;
|
||||
@Mock private IntegrationConfigUsageCheck usageCheck;
|
||||
|
||||
private IntegrationConfigService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service =
|
||||
new IntegrationConfigService(
|
||||
repository,
|
||||
ownership,
|
||||
secretMasker,
|
||||
grantRepository,
|
||||
List.of(validator),
|
||||
List.of(usageCheck));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRejectsAConfigItsTypeValidatorRefuses() {
|
||||
when(secretMasker.sanitize(any())).thenReturn(Map.of());
|
||||
when(validator.type()).thenReturn(IntegrationType.API);
|
||||
org.mockito.Mockito.doThrow(new IllegalArgumentException("api config needs a 'url'"))
|
||||
.when(validator)
|
||||
.validate(any());
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
service.create(
|
||||
request(IntegrationType.API, OwnerScope.USER, null),
|
||||
user(7)))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
e ->
|
||||
assertThat(((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteRefusedWhileAnythingStillReferencesTheConfig() {
|
||||
IntegrationConfig cfg = config(9L);
|
||||
when(repository.findById(9L)).thenReturn(Optional.of(cfg));
|
||||
when(ownership.canManage(any(), eq(cfg), any())).thenReturn(true);
|
||||
when(usageCheck.usagesOf(9L)).thenReturn(List.of("source 'Claims intake'"));
|
||||
|
||||
assertThatThrownBy(() -> service.delete(9L, user(7)))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
e ->
|
||||
assertThat(((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.CONFLICT));
|
||||
verify(repository, org.mockito.Mockito.never()).delete(any(IntegrationConfig.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createDelegatesOwnershipAndSanitizesConfig() {
|
||||
|
||||
+24
@@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -194,6 +195,29 @@ class PolicyControllerTest {
|
||||
assertThat(((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects an ad-hoc output the caller cannot use, on the request thread")
|
||||
void rejectsUnauthorizedAdHocOutput() {
|
||||
// The confused-deputy guard: an S3 output referencing a connection the caller may not
|
||||
// use is validated here (principal present) and refused before any worker dispatch.
|
||||
PipelineDefinition definition =
|
||||
new PipelineDefinition(
|
||||
"pipe",
|
||||
List.of(new PipelineStep("/api/v1/misc/compress-pdf", null)),
|
||||
new OutputSpec("s3", Map.of("connectionId", 999)));
|
||||
doThrow(new IllegalArgumentException("unknown or inaccessible s3 connection"))
|
||||
.when(policyValidator)
|
||||
.validateOutput(any());
|
||||
|
||||
assertThatThrownBy(() -> controller.run(definition, new PolicyRunFiles()))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
e ->
|
||||
assertThat(((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST));
|
||||
verify(policyRunner, never()).runAdHoc(any(), any(), any());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
||||
+22
@@ -82,6 +82,28 @@ class PolicyValidatorTest {
|
||||
assertTrue(ex.getMessage().contains("schedule"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateOutputDelegatesToTheSink() {
|
||||
when(outputSink.supports(any())).thenReturn(true);
|
||||
OutputSpec output = new OutputSpec("s3", Map.of("connectionId", 1));
|
||||
|
||||
validator.validateOutput(output);
|
||||
|
||||
verify(outputSink).validate(output);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateOutputSurfacesAnInaccessibleConnection() {
|
||||
when(outputSink.supports(any())).thenReturn(true);
|
||||
doThrow(new IllegalArgumentException("unknown or inaccessible s3 connection"))
|
||||
.when(outputSink)
|
||||
.validate(any());
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> validator.validateOutput(new OutputSpec("s3", Map.of("connectionId", 1))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnUnknownTriggerType() {
|
||||
when(trigger.type()).thenReturn("schedule");
|
||||
|
||||
+7
-2
@@ -23,6 +23,7 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
|
||||
import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
|
||||
import stirling.software.proprietary.policy.s3.S3TestConnections;
|
||||
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
@@ -82,7 +83,9 @@ class S3InputSourceMinioTest {
|
||||
// The MinIO endpoint resolves to loopback, so the operator opt-in must be on.
|
||||
ApplicationProperties properties = new ApplicationProperties();
|
||||
properties.getPolicies().setAllowPrivateS3Endpoints(true);
|
||||
source = new S3InputSource(new S3ConnectionPool(properties));
|
||||
source =
|
||||
new S3InputSource(
|
||||
new S3ConnectionPool(properties), S3TestConnections.legacyResolver());
|
||||
ledger = new InProcessProcessedLedger();
|
||||
ctx = new RecordingContext();
|
||||
}
|
||||
@@ -161,7 +164,9 @@ class S3InputSourceMinioTest {
|
||||
@Test
|
||||
void aPrivateEndpointIsRejectedWithoutTheOperatorOptIn() {
|
||||
S3InputSource guarded =
|
||||
new S3InputSource(new S3ConnectionPool(new ApplicationProperties()));
|
||||
new S3InputSource(
|
||||
new S3ConnectionPool(new ApplicationProperties()),
|
||||
S3TestConnections.legacyResolver());
|
||||
|
||||
assertThatThrownBy(() -> guarded.validate(spec(Map.of())))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
|
||||
+3
-1
@@ -29,6 +29,7 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
|
||||
import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
|
||||
import stirling.software.proprietary.policy.s3.S3TestConnections;
|
||||
|
||||
import software.amazon.awssdk.core.ResponseInputStream;
|
||||
import software.amazon.awssdk.core.exception.SdkClientException;
|
||||
@@ -64,7 +65,8 @@ class S3InputSourceTest {
|
||||
void setUp() {
|
||||
source =
|
||||
new S3InputSource(
|
||||
new S3ConnectionPool(new ApplicationProperties(), config -> s3Client));
|
||||
new S3ConnectionPool(new ApplicationProperties(), config -> s3Client),
|
||||
S3TestConnections.legacyResolver());
|
||||
ledger = new InProcessProcessedLedger();
|
||||
ctx = new RecordingContext();
|
||||
}
|
||||
|
||||
+3
-2
@@ -28,6 +28,7 @@ import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
|
||||
import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
|
||||
import stirling.software.proprietary.policy.s3.S3TestConnections;
|
||||
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
@@ -90,8 +91,8 @@ class S3OutputSinkMinioTest {
|
||||
properties.getPolicies().setAllowPrivateS3Endpoints(true);
|
||||
S3ConnectionPool pool = new S3ConnectionPool(properties);
|
||||
ledger = new InProcessProcessedLedger();
|
||||
sink = new S3OutputSink(pool, ledger);
|
||||
source = new S3InputSource(pool);
|
||||
sink = new S3OutputSink(pool, S3TestConnections.legacyResolver(), ledger);
|
||||
source = new S3InputSource(pool, S3TestConnections.legacyResolver());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+2
@@ -32,6 +32,7 @@ import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
|
||||
import stirling.software.proprietary.policy.ledger.ProcessedFileStatus;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
|
||||
import stirling.software.proprietary.policy.s3.S3TestConnections;
|
||||
|
||||
import software.amazon.awssdk.awscore.exception.AwsServiceException;
|
||||
import software.amazon.awssdk.core.exception.SdkClientException;
|
||||
@@ -66,6 +67,7 @@ class S3OutputSinkTest {
|
||||
sink =
|
||||
new S3OutputSink(
|
||||
new S3ConnectionPool(new ApplicationProperties(), config -> s3Client),
|
||||
S3TestConnections.legacyResolver(),
|
||||
ledger);
|
||||
}
|
||||
|
||||
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.times;
|
||||
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 java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.proprietary.access.model.OwnerScope;
|
||||
import stirling.software.proprietary.integration.model.IntegrationConfig;
|
||||
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
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.source.InProcessSourceStore;
|
||||
import stirling.software.proprietary.policy.source.Source;
|
||||
import stirling.software.proprietary.policy.store.InProcessPolicyStore;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
|
||||
/**
|
||||
* Tests for {@link EmbeddedS3CredentialMigration}: legacy embedded credentials become deduplicated
|
||||
* team-scoped connections, rewritten rows keep only per-use options, and re-runs are no-ops.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class EmbeddedS3CredentialMigrationTest {
|
||||
|
||||
@Mock private IntegrationConfigRepository connections;
|
||||
@Mock private TeamRepository teamRepository;
|
||||
|
||||
private final InProcessSourceStore sourceStore = new InProcessSourceStore();
|
||||
private final InProcessPolicyStore policyStore = new InProcessPolicyStore();
|
||||
private EmbeddedS3CredentialMigration migration;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
migration =
|
||||
new EmbeddedS3CredentialMigration(
|
||||
sourceStore, policyStore, connections, teamRepository);
|
||||
AtomicLong ids = new AtomicLong(100);
|
||||
// Lenient: the nothing-to-migrate cases never create a connection.
|
||||
lenient().when(connections.findAll()).thenReturn(List.of());
|
||||
lenient()
|
||||
.when(connections.save(any()))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
IntegrationConfig saved = invocation.getArgument(0);
|
||||
if (saved.getId() == null) {
|
||||
saved.setId(ids.incrementAndGet());
|
||||
}
|
||||
return saved;
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractsSharedCredentialsIntoOneTeamScopedConnection() {
|
||||
Team team = new Team();
|
||||
team.setId(7L);
|
||||
when(teamRepository.findById(7L)).thenReturn(Optional.of(team));
|
||||
Source source =
|
||||
sourceStore.save(
|
||||
new Source(
|
||||
null,
|
||||
"Claims intake",
|
||||
"s3",
|
||||
Map.of(
|
||||
"bucket", "inbox",
|
||||
"prefix", "incoming/",
|
||||
"mode", "snapshot",
|
||||
"accessKeyId", "AKIAEXAMPLE",
|
||||
"secretAccessKey", "shh"),
|
||||
true,
|
||||
"alice",
|
||||
7L));
|
||||
Policy policy =
|
||||
policyStore.save(
|
||||
new Policy(
|
||||
null,
|
||||
"Rotate",
|
||||
"alice",
|
||||
true,
|
||||
null,
|
||||
List.of(source.id()),
|
||||
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
|
||||
new OutputSpec(
|
||||
"s3",
|
||||
Map.of(
|
||||
"bucket", "inbox",
|
||||
"prefix", "processed/",
|
||||
"accessKeyId", "AKIAEXAMPLE",
|
||||
"secretAccessKey", "shh")),
|
||||
7L));
|
||||
|
||||
migration.migrate();
|
||||
|
||||
// Same bucket + credentials on both rows: exactly one connection extracted.
|
||||
verify(connections, times(1)).save(any());
|
||||
Map<String, Object> sourceOptions = sourceStore.get(source.id()).orElseThrow().options();
|
||||
assertEquals(101L, sourceOptions.get("connectionId"));
|
||||
assertEquals("incoming/", sourceOptions.get("prefix"));
|
||||
assertEquals("snapshot", sourceOptions.get("mode"));
|
||||
assertNull(sourceOptions.get("accessKeyId"));
|
||||
assertNull(sourceOptions.get("secretAccessKey"));
|
||||
assertNull(sourceOptions.get("bucket"));
|
||||
|
||||
Map<String, Object> outputOptions =
|
||||
policyStore.get(policy.id()).orElseThrow().output().options();
|
||||
assertEquals(101L, outputOptions.get("connectionId"));
|
||||
assertEquals("processed/", outputOptions.get("prefix"));
|
||||
assertNull(outputOptions.get("secretAccessKey"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void connectionOwnershipFollowsTheSourceTeam() {
|
||||
Team team = new Team();
|
||||
team.setId(7L);
|
||||
when(teamRepository.findById(7L)).thenReturn(Optional.of(team));
|
||||
sourceStore.save(s3Source("teamed", 7L));
|
||||
|
||||
migration.migrate();
|
||||
|
||||
verify(connections)
|
||||
.save(
|
||||
org.mockito.ArgumentMatchers.argThat(
|
||||
connection ->
|
||||
connection.getScope() == OwnerScope.TEAM
|
||||
&& connection.getOwnerTeam() == team));
|
||||
}
|
||||
|
||||
@Test
|
||||
void teamlessRowsBecomeServerScopedConnections() {
|
||||
sourceStore.save(s3Source("solo", null));
|
||||
|
||||
migration.migrate();
|
||||
|
||||
verify(connections)
|
||||
.save(
|
||||
org.mockito.ArgumentMatchers.argThat(
|
||||
connection -> connection.getScope() == OwnerScope.SERVER));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSecondRunFindsNothingToDo() {
|
||||
sourceStore.save(s3Source("once", null));
|
||||
|
||||
migration.migrate();
|
||||
migration.migrate();
|
||||
|
||||
// One connection from the first run; the rewritten source no longer embeds credentials.
|
||||
verify(connections, times(1)).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonS3AndAlreadyMigratedRowsAreUntouched() {
|
||||
Source folder =
|
||||
sourceStore.save(
|
||||
new Source(
|
||||
null,
|
||||
"Folder",
|
||||
"folder",
|
||||
Map.of("directory", "/in"),
|
||||
true,
|
||||
"alice",
|
||||
null));
|
||||
Source migrated =
|
||||
sourceStore.save(
|
||||
new Source(
|
||||
null,
|
||||
"Done already",
|
||||
"s3",
|
||||
Map.of("connectionId", 55L, "prefix", "in/"),
|
||||
true,
|
||||
"alice",
|
||||
null));
|
||||
|
||||
migration.migrate();
|
||||
|
||||
verify(connections, times(0)).save(any());
|
||||
assertEquals(
|
||||
Map.of("directory", "/in"), sourceStore.get(folder.id()).orElseThrow().options());
|
||||
assertEquals(
|
||||
Map.of("connectionId", 55L, "prefix", "in/"),
|
||||
sourceStore.get(migrated.id()).orElseThrow().options());
|
||||
}
|
||||
|
||||
private static Source s3Source(String name, Long teamId) {
|
||||
return new Source(
|
||||
null,
|
||||
name,
|
||||
"s3",
|
||||
Map.of(
|
||||
"bucket", "inbox",
|
||||
"accessKeyId", "AKIAEXAMPLE",
|
||||
"secretAccessKey", "shh"),
|
||||
true,
|
||||
"alice",
|
||||
teamId);
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
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.source.InProcessSourceStore;
|
||||
import stirling.software.proprietary.policy.source.Source;
|
||||
import stirling.software.proprietary.policy.store.InProcessPolicyStore;
|
||||
|
||||
/** Tests for {@link PolicyS3ConnectionUsageCheck}'s reference scan across sources and outputs. */
|
||||
class PolicyS3ConnectionUsageCheckTest {
|
||||
|
||||
private final InProcessSourceStore sourceStore = new InProcessSourceStore();
|
||||
private final InProcessPolicyStore policyStore = new InProcessPolicyStore();
|
||||
private final PolicyS3ConnectionUsageCheck check =
|
||||
new PolicyS3ConnectionUsageCheck(sourceStore, policyStore);
|
||||
|
||||
@Test
|
||||
void reportsSourcesAndOutputsReferencingTheConnection() {
|
||||
sourceStore.save(
|
||||
new Source(
|
||||
null,
|
||||
"Claims intake",
|
||||
"s3",
|
||||
Map.of("connectionId", 5L, "prefix", "in/"),
|
||||
true,
|
||||
"alice",
|
||||
null));
|
||||
policyStore.save(
|
||||
new Policy(
|
||||
null,
|
||||
"Rotate",
|
||||
"alice",
|
||||
true,
|
||||
null,
|
||||
List.of(),
|
||||
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
|
||||
new OutputSpec("s3", Map.of("connectionId", "5")),
|
||||
null));
|
||||
|
||||
assertThat(check.usagesOf(5))
|
||||
.containsExactlyInAnyOrder("source 'Claims intake'", "pipeline 'Rotate'");
|
||||
assertThat(check.usagesOf(6)).isEmpty();
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
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.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import stirling.software.proprietary.access.service.OwnershipService;
|
||||
import stirling.software.proprietary.integration.model.IntegrationConfig;
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/**
|
||||
* Tests for {@link S3ConnectionResolver}: connection dereferencing with per-use overrides, the
|
||||
* legacy embedded fallback, and the save-time access check that background sweeps skip.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class S3ConnectionResolverTest {
|
||||
|
||||
@Mock private IntegrationConfigRepository connections;
|
||||
@Mock private OwnershipService ownership;
|
||||
@Mock private UserService userService;
|
||||
|
||||
@AfterEach
|
||||
void clearSecurityContext() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesAConnectionAndMergesPerUseOptions() {
|
||||
when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true)));
|
||||
|
||||
S3Config config =
|
||||
resolver()
|
||||
.resolve(
|
||||
Map.of(
|
||||
"connectionId", 9L,
|
||||
"prefix", "incoming/",
|
||||
"mode", "snapshot"));
|
||||
|
||||
assertEquals("inbox", config.bucket());
|
||||
assertEquals("AKIAEXAMPLE", config.accessKeyId());
|
||||
assertEquals("incoming/", config.prefix());
|
||||
assertTrue(config.snapshot());
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsAStringConnectionReference() {
|
||||
when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true)));
|
||||
|
||||
assertEquals("inbox", resolver().resolve(Map.of("connectionId", "9")).bucket());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToLegacyEmbeddedCredentials() {
|
||||
S3Config config =
|
||||
resolver()
|
||||
.resolve(
|
||||
Map.of(
|
||||
"bucket", "legacy",
|
||||
"accessKeyId", "AKIAEXAMPLE",
|
||||
"secretAccessKey", "shh"));
|
||||
|
||||
assertEquals("legacy", config.bucket());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownDisabledOrWrongTypeConnections() {
|
||||
when(connections.findById(1L)).thenReturn(Optional.empty());
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> resolver().resolve(Map.of("connectionId", 1L)));
|
||||
|
||||
when(connections.findById(2L)).thenReturn(Optional.of(s3Connection(2L, false)));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> resolver().resolve(Map.of("connectionId", 2L)));
|
||||
|
||||
IntegrationConfig mcp = s3Connection(3L, true);
|
||||
mcp.setIntegrationType(IntegrationType.MCP);
|
||||
when(connections.findById(3L)).thenReturn(Optional.of(mcp));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> resolver().resolve(Map.of("connectionId", 3L)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAuthenticatedSaverMustBeAllowedToUseTheConnection() {
|
||||
when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true)));
|
||||
User saver = new User();
|
||||
saver.setUsername("alice");
|
||||
SecurityContextHolder.getContext()
|
||||
.setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(saver, null, java.util.List.of()));
|
||||
when(ownership.canUse(any(), any(IntegrationConfig.class), eq(saver))).thenReturn(false);
|
||||
|
||||
// Denied reads the same as unknown and never echoes the connection name, so ids can't be
|
||||
// enumerated by probing.
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> resolver().resolve(Map.of("connectionId", 9L)));
|
||||
try {
|
||||
resolver().resolve(Map.of("connectionId", 9L));
|
||||
} catch (IllegalArgumentException e) {
|
||||
org.junit.jupiter.api.Assertions.assertFalse(
|
||||
e.getMessage().contains("Claims bucket"),
|
||||
"access-denied error must not leak the connection name");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void backgroundSweepsWithNoUserSkipTheAccessCheck() {
|
||||
when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true)));
|
||||
|
||||
// No authentication in the context: resolution succeeds without consulting ownership.
|
||||
assertEquals("inbox", resolver().resolve(Map.of("connectionId", 9L)).bucket());
|
||||
}
|
||||
|
||||
private S3ConnectionResolver resolver() {
|
||||
return new S3ConnectionResolver(connections, ownership, userService);
|
||||
}
|
||||
|
||||
private static IntegrationConfig s3Connection(long id, boolean enabled) {
|
||||
IntegrationConfig connection = new IntegrationConfig();
|
||||
connection.setId(id);
|
||||
connection.setIntegrationType(IntegrationType.S3);
|
||||
connection.setName("Claims bucket");
|
||||
connection.setEnabled(enabled);
|
||||
connection.setConfig(
|
||||
"{\"bucket\":\"inbox\",\"accessKeyId\":\"AKIAEXAMPLE\","
|
||||
+ "\"secretAccessKey\":\"shh\"}");
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
|
||||
/**
|
||||
* Tests for {@link S3IntegrationValidator}: the S3 connection schema fails at save time - missing
|
||||
* credentials, bad endpoints, and private endpoints without the operator opt-in.
|
||||
*/
|
||||
class S3IntegrationValidatorTest {
|
||||
|
||||
@Test
|
||||
void acceptsACompleteConnection() {
|
||||
assertThatCode(
|
||||
() ->
|
||||
validator(false)
|
||||
.validate(
|
||||
Map.of(
|
||||
"bucket", "inbox",
|
||||
"accessKeyId", "AKIAEXAMPLE",
|
||||
"secretAccessKey", "shh")))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingCredentialsOrBucket() {
|
||||
assertThatThrownBy(() -> validator(false).validate(Map.of("bucket", "inbox")))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
validator(false)
|
||||
.validate(
|
||||
Map.of(
|
||||
"accessKeyId", "AKIAEXAMPLE",
|
||||
"secretAccessKey", "shh")))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAPrivateEndpointWithoutTheOperatorOptIn() {
|
||||
Map<String, Object> config =
|
||||
Map.of(
|
||||
"bucket", "inbox",
|
||||
"accessKeyId", "AKIAEXAMPLE",
|
||||
"secretAccessKey", "shh",
|
||||
"endpoint", "http://localhost:9000");
|
||||
|
||||
assertThatThrownBy(() -> validator(false).validate(config))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("allowPrivateS3Endpoints");
|
||||
assertThatCode(() -> validator(true).validate(config)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void itOnlyClaimsTheS3Type() {
|
||||
org.junit.jupiter.api.Assertions.assertEquals(IntegrationType.S3, validator(false).type());
|
||||
}
|
||||
|
||||
private static S3IntegrationValidator validator(boolean allowPrivateEndpoints) {
|
||||
ApplicationProperties properties = new ApplicationProperties();
|
||||
properties.getPolicies().setAllowPrivateS3Endpoints(allowPrivateEndpoints);
|
||||
return new S3IntegrationValidator(properties);
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import stirling.software.proprietary.access.service.OwnershipService;
|
||||
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/** Test fixtures for S3 connection plumbing shared across the policy S3 tests. */
|
||||
public final class S3TestConnections {
|
||||
|
||||
private S3TestConnections() {}
|
||||
|
||||
/**
|
||||
* A resolver for tests whose options embed credentials directly (the legacy pass-through path),
|
||||
* so its collaborators are never touched.
|
||||
*/
|
||||
public static S3ConnectionResolver legacyResolver() {
|
||||
return new S3ConnectionResolver(
|
||||
mock(IntegrationConfigRepository.class),
|
||||
mock(OwnershipService.class),
|
||||
mock(UserService.class));
|
||||
}
|
||||
}
|
||||
@@ -6534,6 +6534,35 @@ title = "No components available"
|
||||
description = "GA components are available on Pay-as-you-go; a few Beta components are enterprise-only. Locked cards show an upgrade nudge."
|
||||
title = "Some components need a paid plan"
|
||||
|
||||
[portal.connections]
|
||||
createTitle = "New S3 connection"
|
||||
delete = "Delete"
|
||||
edit = "Edit"
|
||||
editTitle = "Edit S3 connection"
|
||||
subtitle = "Reusable S3 credentials that sources and pipeline outputs connect to."
|
||||
|
||||
[portal.connections.actions]
|
||||
new = "New connection"
|
||||
|
||||
[portal.connections.empty]
|
||||
description = "Add an S3 connection to reuse the same bucket and credentials across sources and pipeline outputs."
|
||||
title = "No connections yet"
|
||||
|
||||
[portal.connections.picker]
|
||||
cancel = "Cancel"
|
||||
createNew = "New connection..."
|
||||
placeholder = "Select a connection"
|
||||
save = "Save connection"
|
||||
|
||||
[portal.connections.s3.fields]
|
||||
name = "Connection name"
|
||||
namePlaceholder = "e.g. Claims bucket"
|
||||
|
||||
[portal.connections.table]
|
||||
bucket = "Bucket"
|
||||
name = "Name"
|
||||
region = "Region"
|
||||
|
||||
[portal.docs.authentication]
|
||||
codeCaption = "every request"
|
||||
eyebrow = "GETTING STARTED"
|
||||
@@ -7345,10 +7374,6 @@ operations_one = "Operation ({{count}})"
|
||||
operations_other = "Operations ({{count}})"
|
||||
output = "Output"
|
||||
removeStep = "Remove operation"
|
||||
s3Configure = "Configure"
|
||||
s3Done = "Done"
|
||||
s3ModalTitle = "Amazon S3 output"
|
||||
s3NotConfigured = "Not configured"
|
||||
s3PrefixHelp = "Outputs are uploaded under this key prefix."
|
||||
save = "Save changes"
|
||||
scheduleEvery = "Run every"
|
||||
@@ -7991,34 +8016,29 @@ primaryNav = "Primary navigation"
|
||||
switchApp = "Switch app"
|
||||
|
||||
[portal.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."
|
||||
subtitle = "Reusable input connections that feed documents into Stirling. Configure a source once, then reference it from any number of pipelines."
|
||||
title = "Sources"
|
||||
|
||||
[portal.sources.actions]
|
||||
agentBuilder = "Agent Builder"
|
||||
connectSource = "Connect source"
|
||||
|
||||
[portal.sources.builder]
|
||||
back = "Back to sources"
|
||||
cancel = "Cancel"
|
||||
create = "Create source"
|
||||
createTitle = "Connect a source"
|
||||
delete = "Delete"
|
||||
editTitle = "Edit source"
|
||||
enabled = "Enabled"
|
||||
save = "Save changes"
|
||||
|
||||
[portal.sources.delete]
|
||||
body = "Delete \"{{name}}\"? This can't be undone. Policies that reference it would need to be updated."
|
||||
cancel = "Cancel"
|
||||
confirm = "Delete"
|
||||
title = "Delete source?"
|
||||
|
||||
[portal.sources.detail]
|
||||
closeAriaLabel = "Close detail"
|
||||
delete = "Delete source"
|
||||
docs24h = "Last 24h"
|
||||
docs30d = "Last 30 days"
|
||||
docsTotal = "Total seen"
|
||||
docsTrend = "Documents over the last 30 days"
|
||||
documents = "Documents"
|
||||
edit = "Edit"
|
||||
notReferenced = "Not referenced by any policy, so it's safe to delete."
|
||||
pause = "Pause"
|
||||
resume = "Resume"
|
||||
subtitle = "{{type}} · {{status}}"
|
||||
usedBy = "Used by"
|
||||
|
||||
[portal.sources.empty]
|
||||
description = "Connect a folder (and, soon, cloud storage) so your policies have somewhere to pull documents from."
|
||||
title = "No sources connected yet"
|
||||
@@ -8034,10 +8054,15 @@ disabled = "Disabled"
|
||||
unused = "Unused"
|
||||
|
||||
[portal.sources.table]
|
||||
documents = "Documents"
|
||||
source = "Source"
|
||||
status = "Status"
|
||||
usedBy = "Policies"
|
||||
|
||||
[portal.sources.tabs]
|
||||
connections = "Connections"
|
||||
sources = "Sources"
|
||||
|
||||
[portal.sources.types.editor]
|
||||
description = "Documents your team has processed in the editor, across policy and AI runs."
|
||||
label = "Editor"
|
||||
@@ -8085,6 +8110,10 @@ label = "Access key ID"
|
||||
label = "Bucket"
|
||||
placeholder = "my-company-inbox"
|
||||
|
||||
[portal.sources.types.s3.fields.connection]
|
||||
helperText = "The stored connection holding the bucket and credentials. Reused by every source and pipeline output that references it."
|
||||
label = "Connection"
|
||||
|
||||
[portal.sources.types.s3.fields.endpoint]
|
||||
helperText = "Leave blank for Amazon S3. Set to use an S3-compatible service such as MinIO."
|
||||
label = "Custom endpoint"
|
||||
@@ -8114,22 +8143,10 @@ label = "Secret access key"
|
||||
label = "Source"
|
||||
|
||||
[portal.sources.wizard]
|
||||
back = "Back"
|
||||
cancel = "Cancel"
|
||||
continue = "Continue"
|
||||
editTitle = "Edit source"
|
||||
name = "Name"
|
||||
namePlaceholder = "e.g. Claims intake"
|
||||
save = "Save changes"
|
||||
subtitle = "Step {{current}} of {{total}} · {{label}}"
|
||||
title = "Connect a source"
|
||||
type = "Type"
|
||||
|
||||
[portal.sources.wizard.steps]
|
||||
chooseType = "Choose type"
|
||||
configure = "Configure"
|
||||
review = "Review & connect"
|
||||
|
||||
[portal.tier]
|
||||
enterprise = "Enterprise plan"
|
||||
free = "Editor plan"
|
||||
|
||||
@@ -19,6 +19,12 @@ export interface TableProps<T> {
|
||||
rowKey: (row: T) => string;
|
||||
/** Makes rows interactive (hover + click + keyboard). */
|
||||
onRowClick?: (row: T) => void;
|
||||
/**
|
||||
* Per-row gate for interactivity, checked only when {@link onRowClick} is set. A row for which
|
||||
* this returns false is inert: no click/keyboard, and not announced as a button. Defaults to
|
||||
* all rows interactive.
|
||||
*/
|
||||
isRowInteractive?: (row: T) => boolean;
|
||||
/** Rendered in place of the body when there are no rows. */
|
||||
empty?: ReactNode;
|
||||
className?: string;
|
||||
@@ -35,6 +41,7 @@ export function Table<T>({
|
||||
rows,
|
||||
rowKey,
|
||||
onRowClick,
|
||||
isRowInteractive,
|
||||
empty,
|
||||
className,
|
||||
}: TableProps<T>) {
|
||||
@@ -66,19 +73,22 @@ export function Table<T>({
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
rows.map((row) => {
|
||||
const rowInteractive =
|
||||
interactive && (isRowInteractive?.(row) ?? true);
|
||||
return (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
className={
|
||||
interactive
|
||||
rowInteractive
|
||||
? "sui-table__row sui-table__row--interactive"
|
||||
: "sui-table__row"
|
||||
}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
tabIndex={interactive ? 0 : undefined}
|
||||
role={interactive ? "button" : undefined}
|
||||
onClick={rowInteractive ? () => onRowClick?.(row) : undefined}
|
||||
tabIndex={rowInteractive ? 0 : undefined}
|
||||
role={rowInteractive ? "button" : undefined}
|
||||
onKeyDown={
|
||||
interactive
|
||||
rowInteractive
|
||||
? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
@@ -97,7 +107,8 @@ export function Table<T>({
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Documents } from "@portal/views/Documents";
|
||||
import { Pipelines } from "@portal/views/Pipelines";
|
||||
import { PipelineBuilder } from "@portal/views/PipelineBuilder";
|
||||
import { Sources } from "@portal/views/Sources";
|
||||
import { SourceBuilder } from "@portal/views/SourceBuilder";
|
||||
import { AgentBuilder } from "@portal/views/AgentBuilder";
|
||||
import { Policies } from "@portal/views/Policies";
|
||||
import { Components } from "@portal/views/Components";
|
||||
@@ -36,6 +37,14 @@ export function ViewRouter() {
|
||||
element={<PipelineBuilder />}
|
||||
/>
|
||||
<Route path={rel(VIEW_PATHS.sources)} element={<Sources />} />
|
||||
<Route
|
||||
path={`${rel(VIEW_PATHS.sources)}/new`}
|
||||
element={<SourceBuilder />}
|
||||
/>
|
||||
<Route
|
||||
path={`${rel(VIEW_PATHS.sources)}/:id`}
|
||||
element={<SourceBuilder />}
|
||||
/>
|
||||
<Route
|
||||
path={rel(VIEW_PATHS["agent-builder"])}
|
||||
element={<AgentBuilder />}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Integrations service layer: stored connections (S3 today; MCP/API later) that
|
||||
* policy sources and pipeline outputs reference by id instead of embedding
|
||||
* credentials. Secrets are write-only - reads return them masked, and sending
|
||||
* the mask back on update keeps the stored value.
|
||||
*/
|
||||
import { apiClient } from "@portal/api/http";
|
||||
|
||||
export type IntegrationType = "S3" | "MCP" | "API";
|
||||
export type OwnerScope = "USER" | "TEAM" | "SERVER";
|
||||
|
||||
/** Mirrors the backend IntegrationConfigResponse; `config` values are masked. */
|
||||
export interface IntegrationConfig {
|
||||
id: number;
|
||||
integrationType: IntegrationType;
|
||||
name: string;
|
||||
scope: OwnerScope;
|
||||
ownerUserId: number | null;
|
||||
ownerTeamId: number | null;
|
||||
enabled: boolean;
|
||||
locked: boolean;
|
||||
defaultAccess: string;
|
||||
config: Record<string, unknown>;
|
||||
canManage: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Create/update body; omitted fields keep their stored values on update. */
|
||||
export interface IntegrationConfigRequest {
|
||||
integrationType?: IntegrationType;
|
||||
name?: string;
|
||||
scope?: OwnerScope;
|
||||
ownerTeamId?: number | null;
|
||||
enabled?: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function fetchIntegrations(): Promise<IntegrationConfig[]> {
|
||||
return apiClient.local.json<IntegrationConfig[]>("/api/v1/integrations");
|
||||
}
|
||||
|
||||
/** The S3 connections the caller may use, for source/output pickers. */
|
||||
export async function fetchS3Connections(): Promise<IntegrationConfig[]> {
|
||||
return (await fetchIntegrations()).filter(
|
||||
(integration) => integration.integrationType === "S3",
|
||||
);
|
||||
}
|
||||
|
||||
export async function createIntegration(
|
||||
body: IntegrationConfigRequest,
|
||||
): Promise<IntegrationConfig> {
|
||||
return apiClient.local.json<IntegrationConfig>("/api/v1/integrations", {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateIntegration(
|
||||
id: number,
|
||||
body: IntegrationConfigRequest,
|
||||
): Promise<IntegrationConfig> {
|
||||
return apiClient.local.json<IntegrationConfig>(
|
||||
`/api/v1/integrations/${encodeURIComponent(id)}`,
|
||||
{ method: "PUT", body },
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteIntegration(id: number): Promise<void> {
|
||||
await apiClient.local.json<void>(
|
||||
`/api/v1/integrations/${encodeURIComponent(id)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
}
|
||||
@@ -157,7 +157,7 @@ export function ReviewQueue({ documents, loading }: ReviewQueueProps) {
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
navigate(`${toPortalPath(VIEW_PATHS.sources)}?new`)
|
||||
navigate(`${toPortalPath(VIEW_PATHS.sources)}/new`)
|
||||
}
|
||||
>
|
||||
{t("portal.documents.queue.empty.connectSource")}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ConnectWizard } from "@portal/components/sources/ConnectWizard";
|
||||
|
||||
const meta: Meta<typeof ConnectWizard> = {
|
||||
title: "Portal/Sources/ConnectWizard",
|
||||
component: ConnectWizard,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: { open: true, onClose: () => {}, onCreated: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ConnectWizard>;
|
||||
|
||||
/** Opens on the type-picker step; Continue/Back walk through the three steps. */
|
||||
export const Open: Story = {};
|
||||
|
||||
export const Closed: Story = { args: { open: false } };
|
||||
@@ -1,192 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { HttpError } from "@portal/api/http";
|
||||
import { ConnectWizard } from "@portal/components/sources/ConnectWizard";
|
||||
|
||||
function renderWithMantine(ui: React.ReactElement) {
|
||||
return render(<MantineProvider>{ui}</MantineProvider>);
|
||||
}
|
||||
|
||||
// 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("portal.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("portal.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();
|
||||
|
||||
renderWithMantine(
|
||||
<ConnectWizard open onClose={onClose} onCreated={onCreated} />,
|
||||
);
|
||||
|
||||
stepToReview();
|
||||
|
||||
// Step 2: submit via the final "connect source" action.
|
||||
fireEvent.click(screen.getByText("portal.sources.actions.connectSource"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createSource).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(createSource).toHaveBeenCalledWith({
|
||||
name: "Claims intake",
|
||||
type: "folder",
|
||||
options: {
|
||||
directory: "/data/incoming",
|
||||
mode: "consume",
|
||||
recursive: "false",
|
||||
identity: "stat",
|
||||
},
|
||||
enabled: true,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(onCreated).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("creates an s3 source with a masked secret in review", async () => {
|
||||
createSource.mockResolvedValue({ id: "src-2" });
|
||||
|
||||
renderWithMantine(
|
||||
<ConnectWizard open onClose={vi.fn()} onCreated={vi.fn()} />,
|
||||
);
|
||||
|
||||
// Step 0: pick the S3 type card.
|
||||
fireEvent.click(screen.getByText("portal.sources.types.s3.label"));
|
||||
fireEvent.click(screen.getByText("portal.sources.wizard.continue"));
|
||||
|
||||
// Step 1: name, bucket, credentials. The secret renders as a password
|
||||
// input, so it is not part of the textbox roles.
|
||||
const inputs = screen.getAllByRole("textbox") as HTMLInputElement[];
|
||||
fireEvent.change(inputs[0], { target: { value: "Claims bucket" } });
|
||||
fireEvent.change(inputs[1], { target: { value: "claims-inbox" } });
|
||||
fireEvent.change(inputs[4], { target: { value: "AKIAEXAMPLE" } });
|
||||
const secret = document.querySelector(
|
||||
'input[type="password"]',
|
||||
) as HTMLInputElement;
|
||||
fireEvent.change(secret, { target: { value: "shh-secret" } });
|
||||
fireEvent.click(screen.getByText("portal.sources.wizard.continue"));
|
||||
|
||||
// Step 2: the secret is masked in review, never echoed.
|
||||
expect(screen.queryByText("shh-secret")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("********")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("portal.sources.actions.connectSource"));
|
||||
await waitFor(() => {
|
||||
expect(createSource).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(createSource).toHaveBeenCalledWith({
|
||||
name: "Claims bucket",
|
||||
type: "s3",
|
||||
options: {
|
||||
bucket: "claims-inbox",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
accessKeyId: "AKIAEXAMPLE",
|
||||
secretAccessKey: "shh-secret",
|
||||
endpoint: "",
|
||||
mode: "consume",
|
||||
},
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("edits an existing source: prefilled, skips type, submits with its id", async () => {
|
||||
createSource.mockResolvedValue({ id: "s1" });
|
||||
const onCreated = vi.fn();
|
||||
|
||||
renderWithMantine(
|
||||
<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("portal.sources.wizard.continue"));
|
||||
fireEvent.click(screen.getByText("portal.sources.wizard.save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createSource).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// Options absent from the stored source are submitted at their defaults.
|
||||
expect(createSource).toHaveBeenCalledWith({
|
||||
id: "s1",
|
||||
name: "James",
|
||||
type: "folder",
|
||||
options: {
|
||||
directory: "/data/in",
|
||||
mode: "consume",
|
||||
recursive: "false",
|
||||
identity: "stat",
|
||||
},
|
||||
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",
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithMantine(
|
||||
<ConnectWizard open onClose={vi.fn()} onCreated={vi.fn()} />,
|
||||
);
|
||||
|
||||
stepToReview();
|
||||
fireEvent.click(screen.getByText("portal.sources.actions.connectSource"));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Directory is not readable"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,298 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
FormField,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
StatTile,
|
||||
} from "@app/ui";
|
||||
import { errorMessage } from "@portal/api/http";
|
||||
import { createSource, type Source } from "@portal/api/sources";
|
||||
import { creatableSourceTypes } from "@portal/components/sources/creatableSourceTypes";
|
||||
import {
|
||||
defaultOptions,
|
||||
sourceTypeMeta,
|
||||
type CreatableSourceType,
|
||||
} from "@portal/components/sources/sourceTypes";
|
||||
import "@portal/views/Sources.css";
|
||||
|
||||
const OFFERED_TYPES = creatableSourceTypes();
|
||||
const DEFAULT_TYPE = OFFERED_TYPES[0];
|
||||
|
||||
/** 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 the first offered. */
|
||||
function typeFor(type: string | undefined): CreatableSourceType {
|
||||
return OFFERED_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 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,
|
||||
onCreated,
|
||||
source,
|
||||
}: ConnectWizardProps) {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = source !== undefined;
|
||||
const steps = isEdit ? EDIT_STEPS : CREATE_STEPS;
|
||||
|
||||
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);
|
||||
|
||||
// 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 requiredFilled = type.fields.every(
|
||||
(f) => !f.required || (options[f.key] ?? "").trim() !== "",
|
||||
);
|
||||
const canContinue =
|
||||
stepId === "configure" ? name.trim() !== "" && requiredFilled : true;
|
||||
|
||||
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("portal.sources.wizard.steps.chooseType"),
|
||||
configure: t("portal.sources.wizard.steps.configure"),
|
||||
review: t("portal.sources.wizard.steps.review"),
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width="lg"
|
||||
title={
|
||||
isEdit
|
||||
? t("portal.sources.wizard.editTitle")
|
||||
: t("portal.sources.wizard.title")
|
||||
}
|
||||
subtitle={t("portal.sources.wizard.subtitle", {
|
||||
current: stepIndex + 1,
|
||||
total: steps.length,
|
||||
label: stepLabels[stepId],
|
||||
})}
|
||||
footer={
|
||||
<div className="portal-sources__wizard-footer">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
disabled={submitting}
|
||||
onClick={() =>
|
||||
stepIndex === 0 ? onClose() : setStepIndex((i) => i - 1)
|
||||
}
|
||||
>
|
||||
{stepIndex === 0
|
||||
? t("portal.sources.wizard.cancel")
|
||||
: t("portal.sources.wizard.back")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={advance}
|
||||
loading={submitting}
|
||||
disabled={!canContinue}
|
||||
rightSection={!isLast ? <span aria-hidden>→</span> : undefined}
|
||||
>
|
||||
{!isLast
|
||||
? t("portal.sources.wizard.continue")
|
||||
: isEdit
|
||||
? t("portal.sources.wizard.save")
|
||||
: t("portal.sources.actions.connectSource")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ol className="portal-sources__steps" aria-hidden>
|
||||
{steps.map((id, i) => (
|
||||
<li
|
||||
key={id}
|
||||
className={
|
||||
"portal-sources__step" +
|
||||
(i === stepIndex ? " is-active" : i < stepIndex ? " is-done" : "")
|
||||
}
|
||||
>
|
||||
<span className="portal-sources__step-mark">
|
||||
{i < stepIndex ? "✓" : i + 1}
|
||||
</span>
|
||||
{stepLabels[id]}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{stepId === "type" && (
|
||||
<div className="portal-sources__type-grid">
|
||||
{OFFERED_TYPES.map((ct) => (
|
||||
<Button
|
||||
key={ct.type}
|
||||
variant="tertiary"
|
||||
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>
|
||||
)}
|
||||
|
||||
{stepId === "configure" && (
|
||||
<div className="portal-sources__wizard-body">
|
||||
<FormField label={t("portal.sources.wizard.name")} required>
|
||||
<Input
|
||||
value={name}
|
||||
placeholder={t("portal.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={(value) =>
|
||||
setOptions((o) => ({ ...o, [field.key]: value ?? "" }))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={field.control === "password" ? "password" : undefined}
|
||||
value={options[field.key] ?? ""}
|
||||
placeholder={
|
||||
field.placeholderKey ? t(field.placeholderKey) : undefined
|
||||
}
|
||||
onChange={(e) =>
|
||||
setOptions((o) => ({ ...o, [field.key]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stepId === "review" && (
|
||||
<div className="portal-sources__wizard-body">
|
||||
<div className="portal-sources__stat-grid">
|
||||
<StatTile
|
||||
label={t("portal.sources.wizard.name")}
|
||||
value={name || "—"}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("portal.sources.wizard.type")}
|
||||
value={t(type.labelKey)}
|
||||
/>
|
||||
{type.fields.map((field) => (
|
||||
<StatTile
|
||||
key={field.key}
|
||||
label={t(field.labelKey)}
|
||||
value={
|
||||
field.control === "password" && options[field.key]
|
||||
? "********"
|
||||
: options[field.key] || "—"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{error && <Banner tone="danger" description={error} />}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fireEvent,
|
||||
render as baseRender,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { HttpError } from "@portal/api/http";
|
||||
import { ConnectionsTab } from "@portal/components/sources/ConnectionsTab";
|
||||
import type { IntegrationConfig } from "@portal/api/integrations";
|
||||
|
||||
const render = (ui: Parameters<typeof baseRender>[0]) =>
|
||||
baseRender(ui, { wrapper: MantineProvider });
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { changeLanguage: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
const fetchS3Connections = vi.fn();
|
||||
const deleteIntegration = vi.fn();
|
||||
vi.mock("@portal/api/integrations", () => ({
|
||||
fetchS3Connections: () => fetchS3Connections(),
|
||||
deleteIntegration: (id: number) => deleteIntegration(id),
|
||||
createIntegration: vi.fn(),
|
||||
updateIntegration: vi.fn(),
|
||||
}));
|
||||
|
||||
const CONNECTION = {
|
||||
id: 5,
|
||||
integrationType: "S3",
|
||||
name: "Claims bucket",
|
||||
config: { bucket: "inbox", region: "us-east-1" },
|
||||
canManage: true,
|
||||
} as unknown as IntegrationConfig;
|
||||
|
||||
describe("ConnectionsTab", () => {
|
||||
beforeEach(() => {
|
||||
fetchS3Connections.mockReset();
|
||||
deleteIntegration.mockReset();
|
||||
deleteIntegration.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("shows the empty state when there are no connections", async () => {
|
||||
fetchS3Connections.mockResolvedValue([]);
|
||||
render(<ConnectionsTab />);
|
||||
expect(
|
||||
await screen.findByText("portal.connections.empty.title"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists connections and deletes one", async () => {
|
||||
fetchS3Connections.mockResolvedValueOnce([CONNECTION]);
|
||||
fetchS3Connections.mockResolvedValueOnce([]);
|
||||
render(<ConnectionsTab />);
|
||||
|
||||
expect(await screen.findByText("Claims bucket")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("portal.connections.delete"));
|
||||
await waitFor(() => expect(deleteIntegration).toHaveBeenCalledWith(5));
|
||||
});
|
||||
|
||||
it("surfaces the 409 when deleting a connection still in use", async () => {
|
||||
fetchS3Connections.mockResolvedValue([CONNECTION]);
|
||||
deleteIntegration.mockRejectedValue(
|
||||
new HttpError(409, "Conflict", {
|
||||
detail: "Integration is in use by: source 'Claims intake'",
|
||||
}),
|
||||
);
|
||||
render(<ConnectionsTab />);
|
||||
|
||||
await screen.findByText("Claims bucket");
|
||||
fireEvent.click(screen.getByText("portal.connections.delete"));
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Integration is in use by: source 'Claims intake'",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AddRoundedIcon from "@mui/icons-material/AddRounded";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
EmptyState,
|
||||
Skeleton,
|
||||
Table,
|
||||
type TableColumn,
|
||||
} from "@app/ui";
|
||||
import { errorMessage } from "@portal/api/http";
|
||||
import {
|
||||
deleteIntegration,
|
||||
fetchS3Connections,
|
||||
type IntegrationConfig,
|
||||
} from "@portal/api/integrations";
|
||||
import { SourcesIcon } from "@portal/components/icons";
|
||||
import { S3ConnectionModal } from "@portal/components/sources/S3ConnectionModal";
|
||||
|
||||
/**
|
||||
* The Connections tab of the Sources page: stored S3 connections that sources
|
||||
* and pipeline outputs reference by id. Create/edit go through the shared
|
||||
* {@link S3ConnectionModal}; deleting one the backend still references returns a
|
||||
* 409, surfaced inline.
|
||||
*/
|
||||
export function ConnectionsTab() {
|
||||
const { t } = useTranslation();
|
||||
const [connections, setConnections] = useState<IntegrationConfig[] | null>(
|
||||
null,
|
||||
);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<IntegrationConfig | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setConnections(await fetchS3Connections());
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(connection: IntegrationConfig) {
|
||||
setEditing(connection);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
async function remove(connection: IntegrationConfig) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await deleteIntegration(connection.id);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo<TableColumn<IntegrationConfig>[]>(
|
||||
() => [
|
||||
{
|
||||
key: "name",
|
||||
header: t("portal.connections.table.name"),
|
||||
render: (c) => <strong>{c.name}</strong>,
|
||||
},
|
||||
{
|
||||
key: "bucket",
|
||||
header: t("portal.connections.table.bucket"),
|
||||
render: (c) => (
|
||||
<span className="portal-sources__connections-bucket">
|
||||
{String(c.config?.bucket ?? "")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "region",
|
||||
header: t("portal.connections.table.region"),
|
||||
render: (c) => String(c.config?.region ?? ""),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
render: (c) =>
|
||||
c.canManage ? (
|
||||
<span className="portal-sources__connections-actions">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => openEdit(c)}
|
||||
>
|
||||
{t("portal.connections.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
accent="danger"
|
||||
disabled={busy}
|
||||
onClick={() => void remove(c)}
|
||||
>
|
||||
{t("portal.connections.delete")}
|
||||
</Button>
|
||||
</span>
|
||||
) : null,
|
||||
},
|
||||
],
|
||||
// remove/openEdit are stable enough for this admin surface; busy gates them.
|
||||
[t, busy],
|
||||
);
|
||||
|
||||
const isLoading = connections === null;
|
||||
const isEmpty = connections !== null && connections.length === 0;
|
||||
|
||||
return (
|
||||
<section className="portal-sources__connections">
|
||||
<div className="portal-sources__connections-head">
|
||||
<p className="portal-sources__connections-sub">
|
||||
{t("portal.connections.subtitle")}
|
||||
</p>
|
||||
<Button
|
||||
onClick={openCreate}
|
||||
leftSection={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}
|
||||
>
|
||||
{t("portal.connections.actions.new")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <Banner tone="danger" description={error} />}
|
||||
|
||||
{isLoading && (
|
||||
<div className="portal-sources__table-skeleton" aria-hidden>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} height="3rem" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
icon={<SourcesIcon size={28} />}
|
||||
title={t("portal.connections.empty.title")}
|
||||
description={t("portal.connections.empty.description")}
|
||||
actions={
|
||||
<Button
|
||||
onClick={openCreate}
|
||||
leftSection={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}
|
||||
>
|
||||
{t("portal.connections.actions.new")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{connections !== null && connections.length > 0 && (
|
||||
<Table<IntegrationConfig>
|
||||
className="portal-sources__connections-table"
|
||||
columns={columns}
|
||||
rows={connections}
|
||||
rowKey={(c) => String(c.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<S3ConnectionModal
|
||||
open={modalOpen}
|
||||
connection={editing}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSaved={() => void refresh()}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormField, Input } from "@app/ui";
|
||||
|
||||
/**
|
||||
* The connection-level S3 fields (per-use settings like prefix/mode live on the
|
||||
* source or output referencing the connection). Secrets are write-only: when
|
||||
* editing, the backend returns them masked and keeps the stored value if the
|
||||
* mask is sent back unchanged.
|
||||
*/
|
||||
export interface S3ConnectionFormValues {
|
||||
name: string;
|
||||
bucket: string;
|
||||
region: string;
|
||||
endpoint: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
}
|
||||
|
||||
export const EMPTY_S3_CONNECTION: S3ConnectionFormValues = {
|
||||
name: "",
|
||||
bucket: "",
|
||||
region: "us-east-1",
|
||||
endpoint: "",
|
||||
accessKeyId: "",
|
||||
secretAccessKey: "",
|
||||
};
|
||||
|
||||
export function s3ConnectionRequestConfig(
|
||||
values: S3ConnectionFormValues,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
bucket: values.bucket.trim(),
|
||||
region: values.region.trim(),
|
||||
endpoint: values.endpoint.trim(),
|
||||
accessKeyId: values.accessKeyId.trim(),
|
||||
secretAccessKey: values.secretAccessKey,
|
||||
};
|
||||
}
|
||||
|
||||
export function s3ConnectionFormValid(values: S3ConnectionFormValues): boolean {
|
||||
return (
|
||||
values.name.trim() !== "" &&
|
||||
values.bucket.trim() !== "" &&
|
||||
values.accessKeyId.trim() !== "" &&
|
||||
values.secretAccessKey.trim() !== ""
|
||||
);
|
||||
}
|
||||
|
||||
interface S3ConnectionFormProps {
|
||||
values: S3ConnectionFormValues;
|
||||
onChange: (values: S3ConnectionFormValues) => void;
|
||||
}
|
||||
|
||||
export function S3ConnectionForm({ values, onChange }: S3ConnectionFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const set = (key: keyof S3ConnectionFormValues, value: string) =>
|
||||
onChange({ ...values, [key]: value });
|
||||
|
||||
return (
|
||||
<div className="portal-sources__connection-form">
|
||||
<FormField label={t("portal.connections.s3.fields.name")} required>
|
||||
<Input
|
||||
value={values.name}
|
||||
placeholder={t("portal.connections.s3.fields.namePlaceholder")}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t("portal.sources.types.s3.fields.bucket.label")}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
value={values.bucket}
|
||||
placeholder="my-company-inbox"
|
||||
onChange={(e) => set("bucket", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("portal.sources.types.s3.fields.region.label")}>
|
||||
<Input
|
||||
value={values.region}
|
||||
placeholder="us-east-1"
|
||||
onChange={(e) => set("region", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t("portal.sources.types.s3.fields.accessKeyId.label")}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
value={values.accessKeyId}
|
||||
onChange={(e) => set("accessKeyId", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t("portal.sources.types.s3.fields.secretAccessKey.label")}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={values.secretAccessKey}
|
||||
onChange={(e) => set("secretAccessKey", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t("portal.sources.types.s3.fields.endpoint.label")}
|
||||
helperText={t("portal.sources.types.s3.fields.endpoint.helperText")}
|
||||
>
|
||||
<Input
|
||||
value={values.endpoint}
|
||||
placeholder="https://s3.example.com"
|
||||
onChange={(e) => set("endpoint", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fireEvent,
|
||||
render as baseRender,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { S3ConnectionModal } from "@portal/components/sources/S3ConnectionModal";
|
||||
import type { IntegrationConfig } from "@portal/api/integrations";
|
||||
|
||||
const render = (ui: Parameters<typeof baseRender>[0]) =>
|
||||
baseRender(ui, { wrapper: MantineProvider });
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { changeLanguage: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
const createIntegration = vi.fn();
|
||||
const updateIntegration = vi.fn();
|
||||
vi.mock("@portal/api/integrations", () => ({
|
||||
createIntegration: (...a: unknown[]) => createIntegration(...a),
|
||||
updateIntegration: (...a: unknown[]) => updateIntegration(...a),
|
||||
}));
|
||||
|
||||
function setField(labelPattern: RegExp, value: string) {
|
||||
fireEvent.change(screen.getByLabelText(labelPattern), { target: { value } });
|
||||
}
|
||||
|
||||
describe("S3ConnectionModal", () => {
|
||||
beforeEach(() => {
|
||||
createIntegration.mockReset();
|
||||
updateIntegration.mockReset();
|
||||
});
|
||||
|
||||
it("creates a team-scoped connection from the entered fields", async () => {
|
||||
createIntegration.mockResolvedValue({ id: 5, name: "Claims bucket" });
|
||||
const onSaved = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(<S3ConnectionModal open onClose={onClose} onSaved={onSaved} />);
|
||||
|
||||
setField(/portal\.connections\.s3\.fields\.name/, "Claims bucket");
|
||||
setField(/portal\.sources\.types\.s3\.fields\.bucket\.label/, "inbox");
|
||||
setField(/portal\.sources\.types\.s3\.fields\.accessKeyId\.label/, "AKIA");
|
||||
setField(
|
||||
/portal\.sources\.types\.s3\.fields\.secretAccessKey\.label/,
|
||||
"shh",
|
||||
);
|
||||
fireEvent.click(screen.getByText("portal.connections.picker.save"));
|
||||
|
||||
await waitFor(() => expect(createIntegration).toHaveBeenCalledTimes(1));
|
||||
expect(createIntegration).toHaveBeenCalledWith({
|
||||
integrationType: "S3",
|
||||
name: "Claims bucket",
|
||||
scope: "TEAM",
|
||||
config: {
|
||||
bucket: "inbox",
|
||||
region: "us-east-1",
|
||||
endpoint: "",
|
||||
accessKeyId: "AKIA",
|
||||
secretAccessKey: "shh",
|
||||
},
|
||||
});
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("round-trips a masked secret unchanged on edit (keeps the stored value)", async () => {
|
||||
updateIntegration.mockResolvedValue({ id: 5, name: "Claims bucket" });
|
||||
// The API returns secrets masked; the modal must resend the sentinel verbatim
|
||||
// so the backend keeps the stored secret rather than overwriting it.
|
||||
const connection = {
|
||||
id: 5,
|
||||
integrationType: "S3",
|
||||
name: "Claims bucket",
|
||||
config: {
|
||||
bucket: "inbox",
|
||||
region: "us-east-1",
|
||||
accessKeyId: "AKIA",
|
||||
secretAccessKey: "********",
|
||||
},
|
||||
canManage: true,
|
||||
} as unknown as IntegrationConfig;
|
||||
|
||||
render(
|
||||
<S3ConnectionModal
|
||||
open
|
||||
connection={connection}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const secret = screen.getByLabelText(
|
||||
/portal\.sources\.types\.s3\.fields\.secretAccessKey\.label/,
|
||||
) as HTMLInputElement;
|
||||
expect(secret.value).toBe("********");
|
||||
// Change only the name; leave the masked secret untouched.
|
||||
setField(/portal\.connections\.s3\.fields\.name/, "Renamed bucket");
|
||||
fireEvent.click(screen.getByText("portal.connections.picker.save"));
|
||||
|
||||
await waitFor(() => expect(updateIntegration).toHaveBeenCalledTimes(1));
|
||||
expect(updateIntegration).toHaveBeenCalledWith(
|
||||
5,
|
||||
expect.objectContaining({
|
||||
name: "Renamed bucket",
|
||||
config: expect.objectContaining({ secretAccessKey: "********" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps save disabled until the required fields are present", () => {
|
||||
render(<S3ConnectionModal open onClose={vi.fn()} onSaved={vi.fn()} />);
|
||||
const save = () =>
|
||||
screen.getByText("portal.connections.picker.save").closest("button");
|
||||
|
||||
expect(save()).toBeDisabled();
|
||||
setField(/portal\.connections\.s3\.fields\.name/, "Only a name");
|
||||
expect(save()).toBeDisabled();
|
||||
setField(/portal\.sources\.types\.s3\.fields\.bucket\.label/, "inbox");
|
||||
setField(/portal\.sources\.types\.s3\.fields\.accessKeyId\.label/, "AKIA");
|
||||
setField(
|
||||
/portal\.sources\.types\.s3\.fields\.secretAccessKey\.label/,
|
||||
"shh",
|
||||
);
|
||||
expect(save()).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button, Modal } from "@app/ui";
|
||||
import { errorMessage } from "@portal/api/http";
|
||||
import {
|
||||
createIntegration,
|
||||
updateIntegration,
|
||||
type IntegrationConfig,
|
||||
} from "@portal/api/integrations";
|
||||
import {
|
||||
EMPTY_S3_CONNECTION,
|
||||
S3ConnectionForm,
|
||||
s3ConnectionFormValid,
|
||||
s3ConnectionRequestConfig,
|
||||
type S3ConnectionFormValues,
|
||||
} from "@portal/components/sources/S3ConnectionForm";
|
||||
|
||||
/**
|
||||
* The one place S3 connections are created and edited. Launched from the
|
||||
* Connections tab, the source builder's connection picker, and the pipeline
|
||||
* builder output - so connection setup is always a modal, never inline splat.
|
||||
* Saving validates backend-side (schema, SSRF, credentials); on edit the secret
|
||||
* arrives masked and round-trips unchanged to keep the stored value.
|
||||
*/
|
||||
interface S3ConnectionModalProps {
|
||||
open: boolean;
|
||||
/** When set, edit this connection; otherwise create a new one. */
|
||||
connection?: IntegrationConfig | null;
|
||||
onClose: () => void;
|
||||
/** The saved connection, so callers can select or refresh it. */
|
||||
onSaved: (connection: IntegrationConfig) => void;
|
||||
}
|
||||
|
||||
export function S3ConnectionModal({
|
||||
open,
|
||||
connection,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: S3ConnectionModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState<S3ConnectionFormValues>(EMPTY_S3_CONNECTION);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isEdit = Boolean(connection);
|
||||
|
||||
// Seed the form each time the modal opens (or its target changes).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (connection) {
|
||||
const config = connection.config ?? {};
|
||||
setForm({
|
||||
name: connection.name,
|
||||
bucket: String(config.bucket ?? ""),
|
||||
region: String(config.region ?? "us-east-1"),
|
||||
endpoint: String(config.endpoint ?? ""),
|
||||
accessKeyId: String(config.accessKeyId ?? ""),
|
||||
secretAccessKey: String(config.secretAccessKey ?? ""),
|
||||
});
|
||||
} else {
|
||||
setForm(EMPTY_S3_CONNECTION);
|
||||
}
|
||||
setError(null);
|
||||
}, [open, connection]);
|
||||
|
||||
async function save() {
|
||||
if (saving || !s3ConnectionFormValid(form)) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const saved = connection
|
||||
? await updateIntegration(connection.id, {
|
||||
name: form.name.trim(),
|
||||
config: s3ConnectionRequestConfig(form),
|
||||
})
|
||||
: // TEAM scope suits the team-based portal (the backend defaults the team to the
|
||||
// caller's own). A teamless single-operator self-hosted deployment would need a
|
||||
// USER/SERVER scope choice here - follow-up if the portal ships there.
|
||||
await createIntegration({
|
||||
integrationType: "S3",
|
||||
name: form.name.trim(),
|
||||
scope: "TEAM",
|
||||
config: s3ConnectionRequestConfig(form),
|
||||
});
|
||||
onSaved(saved);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t(
|
||||
isEdit
|
||||
? "portal.connections.editTitle"
|
||||
: "portal.connections.createTitle",
|
||||
)}
|
||||
footer={
|
||||
<div className="portal-sources__connection-create-actions">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
disabled={saving}
|
||||
onClick={onClose}
|
||||
>
|
||||
{t("portal.connections.picker.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
loading={saving}
|
||||
disabled={!s3ConnectionFormValid(form)}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{t("portal.connections.picker.save")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<S3ConnectionForm values={form} onChange={setForm} />
|
||||
{error && <Banner tone="danger" description={error} />}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fireEvent,
|
||||
render as baseRender,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { S3ConnectionPicker } from "@portal/components/sources/S3ConnectionPicker";
|
||||
|
||||
const render = (ui: Parameters<typeof baseRender>[0]) =>
|
||||
baseRender(ui, { wrapper: MantineProvider });
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { changeLanguage: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
const fetchS3Connections = vi.fn();
|
||||
const createIntegration = vi.fn();
|
||||
vi.mock("@portal/api/integrations", () => ({
|
||||
fetchS3Connections: () => fetchS3Connections(),
|
||||
createIntegration: (...a: unknown[]) => createIntegration(...a),
|
||||
updateIntegration: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("S3ConnectionPicker", () => {
|
||||
beforeEach(() => {
|
||||
fetchS3Connections.mockReset();
|
||||
fetchS3Connections.mockResolvedValue([]);
|
||||
createIntegration.mockReset();
|
||||
});
|
||||
|
||||
it("creates a connection inline and selects it", async () => {
|
||||
createIntegration.mockResolvedValue({ id: 7, name: "New bucket" });
|
||||
const onChange = vi.fn();
|
||||
render(<S3ConnectionPicker value="" onChange={onChange} />);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByText("portal.connections.picker.createNew"),
|
||||
);
|
||||
fireEvent.change(
|
||||
screen.getByLabelText(/portal\.connections\.s3\.fields\.name/),
|
||||
{ target: { value: "New bucket" } },
|
||||
);
|
||||
fireEvent.change(
|
||||
screen.getByLabelText(
|
||||
/portal\.sources\.types\.s3\.fields\.bucket\.label/,
|
||||
),
|
||||
{ target: { value: "inbox" } },
|
||||
);
|
||||
fireEvent.change(
|
||||
screen.getByLabelText(
|
||||
/portal\.sources\.types\.s3\.fields\.accessKeyId\.label/,
|
||||
),
|
||||
{ target: { value: "AKIA" } },
|
||||
);
|
||||
fireEvent.change(
|
||||
screen.getByLabelText(
|
||||
/portal\.sources\.types\.s3\.fields\.secretAccessKey\.label/,
|
||||
),
|
||||
{ target: { value: "shh" } },
|
||||
);
|
||||
fireEvent.click(screen.getByText("portal.connections.picker.save"));
|
||||
|
||||
await waitFor(() => expect(createIntegration).toHaveBeenCalledTimes(1));
|
||||
// The newly created connection's id is selected in the parent.
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalledWith("7"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button, Select } from "@app/ui";
|
||||
import { errorMessage } from "@portal/api/http";
|
||||
import {
|
||||
fetchS3Connections,
|
||||
type IntegrationConfig,
|
||||
} from "@portal/api/integrations";
|
||||
import { S3ConnectionModal } from "@portal/components/sources/S3ConnectionModal";
|
||||
|
||||
/**
|
||||
* Selects a stored S3 connection by id. Creating a new one opens the shared
|
||||
* connection modal (saved immediately and validated backend-side), so the
|
||||
* parent only ever sees a real connection id.
|
||||
*/
|
||||
interface S3ConnectionPickerProps {
|
||||
value: string;
|
||||
onChange: (connectionId: string) => void;
|
||||
}
|
||||
|
||||
export function S3ConnectionPicker({
|
||||
value,
|
||||
onChange,
|
||||
}: S3ConnectionPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [connections, setConnections] = useState<IntegrationConfig[] | null>(
|
||||
null,
|
||||
);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
fetchS3Connections()
|
||||
.then((list) => {
|
||||
if (mounted) setConnections(list);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (mounted) setError(errorMessage(e));
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="portal-sources__connection-picker">
|
||||
<Select
|
||||
value={value || null}
|
||||
placeholder={t("portal.connections.picker.placeholder")}
|
||||
options={(connections ?? []).map((connection) => ({
|
||||
value: String(connection.id),
|
||||
label: connection.name,
|
||||
}))}
|
||||
onChange={(selected) => onChange(selected ?? "")}
|
||||
/>
|
||||
<Button variant="tertiary" size="sm" onClick={() => setModalOpen(true)}>
|
||||
{t("portal.connections.picker.createNew")}
|
||||
</Button>
|
||||
{error && <Banner tone="danger" description={error} />}
|
||||
<S3ConnectionModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSaved={(created) => {
|
||||
setConnections((list) => [...(list ?? []), created]);
|
||||
onChange(String(created.id));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { SourceView } from "@portal/api/sources";
|
||||
import { SourceDetailCard } from "@portal/components/sources/SourceDetailCard";
|
||||
import { sampleDailySeries } from "@portal/mocks/sampleDailySeries";
|
||||
|
||||
const SAMPLE_SERIES = sampleDailySeries(330);
|
||||
|
||||
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: 45230,
|
||||
docs24h: 312,
|
||||
docs30d: 9870,
|
||||
};
|
||||
|
||||
const ORPHANED: SourceView = {
|
||||
id: "src-archive",
|
||||
name: "Archive reprocess",
|
||||
type: "folder",
|
||||
status: "unused",
|
||||
referenceCount: 0,
|
||||
referencingPolicies: [],
|
||||
config: [{ label: "Directory", value: "/data/archive" }],
|
||||
docsTotal: 45230,
|
||||
docs24h: 312,
|
||||
docs30d: 9870,
|
||||
};
|
||||
|
||||
const meta: Meta<typeof SourceDetailCard> = {
|
||||
title: "Portal/Sources/SourceDetailCard",
|
||||
component: SourceDetailCard,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
docSeries: SAMPLE_SERIES,
|
||||
onClose: () => {},
|
||||
onEdit: () => {},
|
||||
onTogglePause: () => {},
|
||||
onDelete: () => {},
|
||||
},
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "56rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SourceDetailCard>;
|
||||
|
||||
export const InUse: Story = { args: { source: IN_USE } };
|
||||
export const Orphaned: Story = { args: { source: ORPHANED } };
|
||||
@@ -1,100 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActionIcon, Button } from "@app/ui";
|
||||
import type { SourceView } from "@portal/api/sources";
|
||||
import { SourceDetailPanel } from "@portal/components/sources/SourceDetailPanel";
|
||||
import {
|
||||
EDITOR_SOURCE_TYPE,
|
||||
sourceTypeMeta,
|
||||
} from "@portal/components/sources/sourceTypes";
|
||||
import "@portal/views/Sources.css";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
|
||||
interface SourceDetailCardProps {
|
||||
source: SourceView;
|
||||
docSeries: number[];
|
||||
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 detail for the selected source row, with edit/pause/delete actions. */
|
||||
export function SourceDetailCard({
|
||||
source,
|
||||
docSeries,
|
||||
onClose,
|
||||
onEdit,
|
||||
onTogglePause,
|
||||
onDelete,
|
||||
busy = false,
|
||||
}: SourceDetailCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const meta = sourceTypeMeta(source.type);
|
||||
const paused = source.status === "disabled";
|
||||
// The editor is a built-in source: it has no instance name and can't be edited/paused/deleted.
|
||||
const isEditor = source.type === EDITOR_SOURCE_TYPE;
|
||||
return (
|
||||
<section className="portal-sources__expanded">
|
||||
<header className="portal-sources__expanded-head">
|
||||
<span
|
||||
className={`portal-sources__type-dot portal-sources__type-dot--${meta.accent}`}
|
||||
aria-hidden
|
||||
>
|
||||
{meta.icon}
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="portal-sources__expanded-title">
|
||||
{isEditor ? t(meta.labelKey) : source.name}
|
||||
</h2>
|
||||
<span className="portal-sources__expanded-sub">
|
||||
{t("portal.sources.detail.subtitle", {
|
||||
type: t(meta.labelKey),
|
||||
status: t(`portal.sources.status.${source.status}`),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
className="portal-sources__expanded-close"
|
||||
onClick={onClose}
|
||||
aria-label={t("portal.sources.detail.closeAriaLabel")}
|
||||
>
|
||||
<CloseIcon />
|
||||
</ActionIcon>
|
||||
</header>
|
||||
|
||||
<SourceDetailPanel source={source} docSeries={docSeries} />
|
||||
|
||||
{!isEditor && (
|
||||
<div className="portal-sources__detail-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={busy}
|
||||
onClick={() => onEdit(source)}
|
||||
>
|
||||
{t("portal.sources.detail.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={busy}
|
||||
onClick={() => onTogglePause(source)}
|
||||
>
|
||||
{paused
|
||||
? t("portal.sources.detail.resume")
|
||||
: t("portal.sources.detail.pause")}
|
||||
</Button>
|
||||
<Button
|
||||
accent="danger"
|
||||
variant="secondary"
|
||||
disabled={busy}
|
||||
onClick={() => onDelete(source)}
|
||||
>
|
||||
{t("portal.sources.detail.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { SourceView } from "@portal/api/sources";
|
||||
import { SourceDetailPanel } from "@portal/components/sources/SourceDetailPanel";
|
||||
import { sampleDailySeries } from "@portal/mocks/sampleDailySeries";
|
||||
|
||||
const SAMPLE_SERIES = sampleDailySeries(330);
|
||||
|
||||
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: 45230,
|
||||
docs24h: 312,
|
||||
docs30d: 9870,
|
||||
};
|
||||
|
||||
const ORPHANED: SourceView = {
|
||||
id: "src-archive",
|
||||
name: "Archive reprocess",
|
||||
type: "folder",
|
||||
status: "unused",
|
||||
referenceCount: 0,
|
||||
referencingPolicies: [],
|
||||
config: [{ label: "Directory", value: "/data/archive" }],
|
||||
docsTotal: 1180,
|
||||
docs24h: 0,
|
||||
docs30d: 0,
|
||||
};
|
||||
|
||||
const meta: Meta<typeof SourceDetailPanel> = {
|
||||
title: "Portal/Sources/SourceDetailPanel",
|
||||
component: SourceDetailPanel,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "48rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SourceDetailPanel>;
|
||||
|
||||
export const InUse: Story = {
|
||||
args: { source: IN_USE, docSeries: SAMPLE_SERIES },
|
||||
};
|
||||
/** A source no policy references is called out as safe to delete. */
|
||||
export const Orphaned: Story = { args: { source: ORPHANED, docSeries: [] } };
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Chip, StatTile } from "@app/ui";
|
||||
import type { SourceView } from "@portal/api/sources";
|
||||
import { Sparkline } from "@portal/components/sources/Sparkline";
|
||||
import { EDITOR_SOURCE_TYPE } from "@portal/components/sources/sourceTypes";
|
||||
import "@portal/views/Sources.css";
|
||||
|
||||
interface SourceDetailPanelProps {
|
||||
source: SourceView;
|
||||
/** The 30-day daily series for the sparkline, fetched per source when expanded. */
|
||||
docSeries: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Expanded detail for a source row: its config (key/value), the documents it has
|
||||
* fed into runs, and which policies reference it (a 0-reference source is called
|
||||
* out as safe to delete).
|
||||
*/
|
||||
export function SourceDetailPanel({
|
||||
source,
|
||||
docSeries,
|
||||
}: SourceDetailPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const isEditor = source.type === EDITOR_SOURCE_TYPE;
|
||||
return (
|
||||
<div className="portal-sources__detail">
|
||||
{isEditor && (
|
||||
<p className="portal-sources__muted">
|
||||
{t("portal.sources.types.editor.description")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{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("portal.sources.detail.usedBy")}
|
||||
</span>
|
||||
{source.referencingPolicies.length === 0 ? (
|
||||
<p className="portal-sources__muted">
|
||||
{t(
|
||||
isEditor
|
||||
? "portal.sources.types.editor.noPolicies"
|
||||
: "portal.sources.detail.notReferenced",
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<div className="portal-sources__chips">
|
||||
{source.referencingPolicies.map((policy) => (
|
||||
<Chip key={policy.id} size="sm">
|
||||
{policy.name}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="portal-sources__detail-section">
|
||||
<span className="portal-sources__detail-heading">
|
||||
{t("portal.sources.detail.documents")}
|
||||
</span>
|
||||
<div className="portal-sources__stat-grid">
|
||||
<StatTile
|
||||
label={t("portal.sources.detail.docsTotal")}
|
||||
value={source.docsTotal.toLocaleString()}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("portal.sources.detail.docs24h")}
|
||||
value={source.docs24h.toLocaleString()}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("portal.sources.detail.docs30d")}
|
||||
value={source.docs30d.toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
{docSeries.length > 0 && (
|
||||
<Sparkline
|
||||
data={docSeries}
|
||||
ariaLabel={t("portal.sources.detail.docsTrend")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,14 +51,9 @@ const meta: Meta<typeof SourcesTable> = {
|
||||
title: "Portal/Sources/SourcesTable",
|
||||
component: SourcesTable,
|
||||
parameters: { layout: "padded" },
|
||||
args: { sources: SOURCES, expandedId: null, onRowClick: () => {} },
|
||||
args: { sources: SOURCES, onRowClick: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SourcesTable>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A row with an open detail panel rotates its caret. */
|
||||
export const RowExpanded: Story = {
|
||||
args: { expandedId: SOURCES[0].id },
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ChevronRightRoundedIcon from "@mui/icons-material/ChevronRightRounded";
|
||||
import {
|
||||
Chip,
|
||||
StatusBadge,
|
||||
@@ -22,16 +23,11 @@ const STATUS_TONE: Record<SourceStatus, StatusTone> = {
|
||||
|
||||
interface SourcesTableProps {
|
||||
sources: SourceView[];
|
||||
/** Id of the row whose detail panel is open, drives the caret state. */
|
||||
expandedId: string | null;
|
||||
/** Opens a source's own page. Not called for the virtual editor row. */
|
||||
onRowClick: (source: SourceView) => void;
|
||||
}
|
||||
|
||||
export function SourcesTable({
|
||||
sources,
|
||||
expandedId,
|
||||
onRowClick,
|
||||
}: SourcesTableProps) {
|
||||
export function SourcesTable({ sources, onRowClick }: SourcesTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const columns = useMemo<TableColumn<SourceView>[]>(
|
||||
() => [
|
||||
@@ -76,6 +72,18 @@ export function SourcesTable({
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "docs",
|
||||
header: t("portal.sources.table.documents"),
|
||||
align: "right",
|
||||
render: (s) => (
|
||||
<span
|
||||
className={s.docsTotal === 0 ? "portal-sources__muted" : undefined}
|
||||
>
|
||||
{s.docsTotal.toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "referenceCount",
|
||||
header: t("portal.sources.table.usedBy"),
|
||||
@@ -91,23 +99,20 @@ export function SourcesTable({
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "expand",
|
||||
key: "open",
|
||||
header: "",
|
||||
align: "right",
|
||||
width: "2.5rem",
|
||||
render: (s) => (
|
||||
<span
|
||||
className={
|
||||
"portal-sources__caret" + (expandedId === s.id ? " is-open" : "")
|
||||
}
|
||||
aria-hidden
|
||||
>
|
||||
▸
|
||||
// The editor source has no page to open, so it shows no chevron.
|
||||
render: (s) =>
|
||||
s.type === EDITOR_SOURCE_TYPE ? null : (
|
||||
<span className="portal-sources__caret" aria-hidden>
|
||||
<ChevronRightRoundedIcon style={{ fontSize: "1.25rem" }} />
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[expandedId, t],
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -117,6 +122,8 @@ export function SourcesTable({
|
||||
rows={sources}
|
||||
rowKey={(s) => s.id}
|
||||
onRowClick={onRowClick}
|
||||
// The virtual editor row has no page to open, so it's inert - not a fake button.
|
||||
isRowInteractive={(s) => s.type !== EDITOR_SOURCE_TYPE}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { Sparkline } from "@portal/components/sources/Sparkline";
|
||||
|
||||
function pointsOf(container: HTMLElement): string[] {
|
||||
const poly = container.querySelector("polyline");
|
||||
return (poly?.getAttribute("points") ?? "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
describe("Sparkline", () => {
|
||||
it("draws one point per value", () => {
|
||||
const { container } = render(<Sparkline data={[1, 5, 2, 8, 3]} />);
|
||||
expect(pointsOf(container)).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("renders nothing for an empty series", () => {
|
||||
const { container } = render(<Sparkline data={[]} />);
|
||||
expect(container.querySelector("svg")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a flat finite line for an all-zero series (no divide-by-zero)", () => {
|
||||
const { container } = render(<Sparkline data={[0, 0, 0, 0]} />);
|
||||
const ys = pointsOf(container).map((p) => Number(p.split(",")[1]));
|
||||
expect(ys).toHaveLength(4);
|
||||
expect(ys.every(Number.isFinite)).toBe(true);
|
||||
// All equal: a zero series is a single horizontal line, not NaN-laden.
|
||||
expect(new Set(ys).size).toBe(1);
|
||||
});
|
||||
|
||||
it("puts the peak value at the top of the band", () => {
|
||||
const { container } = render(<Sparkline data={[0, 10]} height={36} />);
|
||||
const ys = pointsOf(container).map((p) => Number(p.split(",")[1]));
|
||||
// y grows downward in SVG, so the larger value (10) sits at the smaller y.
|
||||
expect(ys[1]).toBeLessThan(ys[0]);
|
||||
});
|
||||
|
||||
it("exposes its aria-label", () => {
|
||||
const { container } = render(
|
||||
<Sparkline data={[1, 2]} ariaLabel="Docs trend" />,
|
||||
);
|
||||
expect(container.querySelector("svg")?.getAttribute("aria-label")).toBe(
|
||||
"Docs trend",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
import "@portal/views/Sources.css";
|
||||
|
||||
interface SparklineProps {
|
||||
/** Series values, oldest first. */
|
||||
data: number[];
|
||||
/** Drawing height in px; the width fills the container. */
|
||||
height?: number;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A tiny dependency-free trend line: normalises {@code data} to its own peak and draws a single
|
||||
* polyline. The viewBox is fixed while the rendered width fills the container (the stroke stays
|
||||
* crisp via non-scaling-stroke), so it adapts to any column without distorting the line weight.
|
||||
*/
|
||||
export function Sparkline({ data, height = 36, ariaLabel }: SparklineProps) {
|
||||
if (data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const width = 240;
|
||||
const pad = 3;
|
||||
const max = Math.max(...data, 1);
|
||||
const stepX = data.length > 1 ? width / (data.length - 1) : 0;
|
||||
const points = data
|
||||
.map((value, i) => {
|
||||
const x = i * stepX;
|
||||
const y = pad + (1 - value / max) * (height - 2 * pad);
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="portal-sparkline"
|
||||
width="100%"
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export function sourceTypeMeta(type: string): SourceTypeMeta {
|
||||
export interface SourceFieldDef {
|
||||
key: string;
|
||||
labelKey: string;
|
||||
control: "text" | "password" | "select";
|
||||
control: "text" | "password" | "select" | "s3Connection";
|
||||
required?: boolean;
|
||||
placeholderKey?: string;
|
||||
helperTextKey?: string;
|
||||
@@ -148,18 +148,11 @@ export const CREATABLE_SOURCE_TYPES: CreatableSourceType[] = [
|
||||
descriptionKey: "portal.sources.types.s3.description",
|
||||
fields: [
|
||||
{
|
||||
key: "bucket",
|
||||
labelKey: "portal.sources.types.s3.fields.bucket.label",
|
||||
control: "text",
|
||||
key: "connectionId",
|
||||
labelKey: "portal.sources.types.s3.fields.connection.label",
|
||||
control: "s3Connection",
|
||||
required: true,
|
||||
placeholderKey: "portal.sources.types.s3.fields.bucket.placeholder",
|
||||
},
|
||||
{
|
||||
key: "region",
|
||||
labelKey: "portal.sources.types.s3.fields.region.label",
|
||||
control: "text",
|
||||
defaultValue: "us-east-1",
|
||||
placeholderKey: "portal.sources.types.s3.fields.region.placeholder",
|
||||
helperTextKey: "portal.sources.types.s3.fields.connection.helperText",
|
||||
},
|
||||
{
|
||||
key: "prefix",
|
||||
@@ -168,25 +161,6 @@ export const CREATABLE_SOURCE_TYPES: CreatableSourceType[] = [
|
||||
placeholderKey: "portal.sources.types.s3.fields.prefix.placeholder",
|
||||
helperTextKey: "portal.sources.types.s3.fields.prefix.helperText",
|
||||
},
|
||||
{
|
||||
key: "accessKeyId",
|
||||
labelKey: "portal.sources.types.s3.fields.accessKeyId.label",
|
||||
control: "text",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "secretAccessKey",
|
||||
labelKey: "portal.sources.types.s3.fields.secretAccessKey.label",
|
||||
control: "password",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "endpoint",
|
||||
labelKey: "portal.sources.types.s3.fields.endpoint.label",
|
||||
control: "text",
|
||||
placeholderKey: "portal.sources.types.s3.fields.endpoint.placeholder",
|
||||
helperTextKey: "portal.sources.types.s3.fields.endpoint.helperText",
|
||||
},
|
||||
{
|
||||
key: "mode",
|
||||
labelKey: "portal.sources.types.s3.fields.mode.label",
|
||||
|
||||
@@ -392,27 +392,3 @@
|
||||
min-width: 1.5rem;
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
|
||||
.portal-builder__s3-output {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.portal-builder__s3-summary {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.portal-builder__s3-summary.is-unset {
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-builder__s3-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,13 @@ vi.mock("@portal/api/policies", () => ({
|
||||
clearProcessedHistory: (id: string) => clearProcessedHistory(id),
|
||||
}));
|
||||
|
||||
const fetchS3Connections = vi.fn();
|
||||
const createIntegration = vi.fn();
|
||||
vi.mock("@portal/api/integrations", () => ({
|
||||
fetchS3Connections: () => fetchS3Connections(),
|
||||
createIntegration: (...args: unknown[]) => createIntegration(...args),
|
||||
}));
|
||||
|
||||
// One editable tool, Compress, so the picker and step settings have something to render.
|
||||
vi.mock("@app/contexts/ToolRegistryContext", () => {
|
||||
const compress = {
|
||||
@@ -149,6 +156,9 @@ describe("PipelineBuilder", () => {
|
||||
fetchRun.mockResolvedValue({ status: "COMPLETED" });
|
||||
clearProcessedHistory.mockReset();
|
||||
clearProcessedHistory.mockResolvedValue(undefined);
|
||||
fetchS3Connections.mockReset();
|
||||
fetchS3Connections.mockResolvedValue([]);
|
||||
createIntegration.mockReset();
|
||||
});
|
||||
|
||||
it("builds a new pipeline: name it, add a tool, and save", async () => {
|
||||
@@ -177,7 +187,8 @@ describe("PipelineBuilder", () => {
|
||||
expect(await screen.findByText("pipelines list")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves an s3 output with its connection options", async () => {
|
||||
it("saves an s3 output referencing an inline-created connection", async () => {
|
||||
createIntegration.mockResolvedValue({ id: 12, name: "Claims bucket" });
|
||||
renderBuilder("/processor/pipelines/new");
|
||||
|
||||
fireEvent.change(await screen.findByRole("textbox"), {
|
||||
@@ -185,32 +196,55 @@ describe("PipelineBuilder", () => {
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("portal.pipelines.output.s3"));
|
||||
|
||||
// With s3 selected but no bucket, saving is blocked and the summary reads
|
||||
// unconfigured; the connection fields live behind the Configure modal.
|
||||
// With s3 selected but no connection chosen, saving is blocked. The
|
||||
// connection picker + prefix are inline (no modal), like the folder output.
|
||||
expect(
|
||||
screen.getByText("portal.pipelines.composer.create").closest("button"),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText("portal.pipelines.composer.s3NotConfigured"),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("portal.pipelines.composer.s3Configure"));
|
||||
|
||||
// Textboxes: name, then the modal's bucket, region, prefix, access key id,
|
||||
// endpoint; the secret renders as a password input outside the textbox role.
|
||||
const inputs = screen.getAllByRole("textbox") as HTMLInputElement[];
|
||||
fireEvent.change(inputs[1], { target: { value: "claims-processed" } });
|
||||
fireEvent.change(inputs[3], { target: { value: "processed/" } });
|
||||
fireEvent.change(inputs[4], { target: { value: "AKIAEXAMPLE" } });
|
||||
const secret = document.querySelector(
|
||||
'input[type="password"]',
|
||||
) as HTMLInputElement;
|
||||
fireEvent.change(secret, { target: { value: "shh-secret" } });
|
||||
fireEvent.click(screen.getByText("portal.pipelines.composer.s3Done"));
|
||||
|
||||
// The summary now shows the configured destination.
|
||||
// No connections exist: create one inline from the picker. Target fields by
|
||||
// label, not position - the picker's Mantine Select also carries an input
|
||||
// role and would shift index-based queries.
|
||||
fireEvent.click(
|
||||
await screen.findByText("portal.connections.picker.createNew"),
|
||||
);
|
||||
fireEvent.change(
|
||||
screen.getByLabelText(/portal\.connections\.s3\.fields\.name/),
|
||||
{ target: { value: "Claims bucket" } },
|
||||
);
|
||||
fireEvent.change(
|
||||
screen.getByLabelText(
|
||||
/portal\.sources\.types\.s3\.fields\.bucket\.label/,
|
||||
),
|
||||
{ target: { value: "claims-processed" } },
|
||||
);
|
||||
fireEvent.change(
|
||||
screen.getByLabelText(
|
||||
/portal\.sources\.types\.s3\.fields\.accessKeyId\.label/,
|
||||
),
|
||||
{ target: { value: "AKIAEXAMPLE" } },
|
||||
);
|
||||
fireEvent.change(
|
||||
screen.getByLabelText(
|
||||
/portal\.sources\.types\.s3\.fields\.secretAccessKey\.label/,
|
||||
),
|
||||
{ target: { value: "shh-secret" } },
|
||||
);
|
||||
fireEvent.click(screen.getByText("portal.connections.picker.save"));
|
||||
await waitFor(() => expect(createIntegration).toHaveBeenCalledTimes(1));
|
||||
// The connection modal closes once saved and the connection is selected.
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText("s3://claims-processed/processed/"),
|
||||
).toBeInTheDocument();
|
||||
screen.queryByText("portal.connections.picker.save"),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
fireEvent.change(
|
||||
screen.getByLabelText(
|
||||
/portal\.sources\.types\.s3\.fields\.prefix\.label/,
|
||||
),
|
||||
{ target: { value: "processed/" } },
|
||||
);
|
||||
fireEvent.click(screen.getByText("portal.pipelines.composer.create"));
|
||||
|
||||
await waitFor(() => expect(savePipeline).toHaveBeenCalledTimes(1));
|
||||
@@ -219,12 +253,8 @@ describe("PipelineBuilder", () => {
|
||||
output: {
|
||||
type: "s3",
|
||||
options: {
|
||||
bucket: "claims-processed",
|
||||
region: "us-east-1",
|
||||
connectionId: "12",
|
||||
prefix: "processed/",
|
||||
endpoint: "",
|
||||
accessKeyId: "AKIAEXAMPLE",
|
||||
secretAccessKey: "shh-secret",
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
} from "@portal/api/pipelines";
|
||||
import { clearProcessedHistory } from "@portal/api/policies";
|
||||
import { availableOutputModes } from "@portal/components/pipelines/outputModes";
|
||||
import { S3ConnectionPicker } from "@portal/components/sources/S3ConnectionPicker";
|
||||
import { fetchSources, type SourceView } from "@portal/api/sources";
|
||||
import { EDITOR_SOURCE_TYPE } from "@portal/components/sources/sourceTypes";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
@@ -64,23 +65,15 @@ type OutputMode = PipelineOutputMode;
|
||||
/** New pipelines (and specs of unoffered types) start on the first offered destination. */
|
||||
const DEFAULT_OUTPUT_MODE = availableOutputModes()[0];
|
||||
|
||||
/** The s3 output's connection fields, mirrored from the OutputSpec options. */
|
||||
/** The s3 output's options: a stored connection reference plus the per-use prefix. */
|
||||
interface S3OutputOptions {
|
||||
bucket: string;
|
||||
region: string;
|
||||
connectionId: string;
|
||||
prefix: string;
|
||||
endpoint: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
}
|
||||
|
||||
const EMPTY_S3_OUTPUT: S3OutputOptions = {
|
||||
bucket: "",
|
||||
region: "us-east-1",
|
||||
connectionId: "",
|
||||
prefix: "",
|
||||
endpoint: "",
|
||||
accessKeyId: "",
|
||||
secretAccessKey: "",
|
||||
};
|
||||
type ScheduleUnit = "MINUTES" | "HOURS" | "DAYS";
|
||||
|
||||
@@ -133,18 +126,12 @@ function parseOutput(output: OutputSpec | undefined): {
|
||||
};
|
||||
}
|
||||
if (output?.type === "s3") {
|
||||
const option = (key: keyof S3OutputOptions, fallback = "") =>
|
||||
String(output.options?.[key] ?? fallback);
|
||||
return {
|
||||
mode: "s3",
|
||||
directory: "",
|
||||
s3: {
|
||||
bucket: option("bucket"),
|
||||
region: option("region", "us-east-1"),
|
||||
prefix: option("prefix"),
|
||||
endpoint: option("endpoint"),
|
||||
accessKeyId: option("accessKeyId"),
|
||||
secretAccessKey: option("secretAccessKey"),
|
||||
connectionId: String(output.options?.connectionId ?? ""),
|
||||
prefix: String(output.options?.prefix ?? ""),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -203,7 +190,6 @@ export function PipelineBuilder() {
|
||||
const [outputMode, setOutputMode] = useState<OutputMode>(DEFAULT_OUTPUT_MODE);
|
||||
const [outputDirectory, setOutputDirectory] = useState("");
|
||||
const [outputS3, setOutputS3] = useState<S3OutputOptions>(EMPTY_S3_OUTPUT);
|
||||
const [s3ConfigOpen, setS3ConfigOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [seeded, setSeeded] = useState(false);
|
||||
@@ -277,10 +263,6 @@ export function PipelineBuilder() {
|
||||
if (selected && !triggerAvailable(selected)) setTriggerType(MANUAL);
|
||||
}, [triggerType, triggers, triggerAvailable]);
|
||||
|
||||
function setS3Field(key: keyof S3OutputOptions, value: string) {
|
||||
setOutputS3((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function toggleSource(sourceId: string, checked: boolean) {
|
||||
setSourceIds((ids) =>
|
||||
checked
|
||||
@@ -356,10 +338,7 @@ export function PipelineBuilder() {
|
||||
const scheduleCountValid =
|
||||
triggerType !== "schedule" || Number(scheduleCount) > 0;
|
||||
const s3OutputValid =
|
||||
outputMode !== "s3" ||
|
||||
(outputS3.bucket.trim() !== "" &&
|
||||
outputS3.accessKeyId.trim() !== "" &&
|
||||
outputS3.secretAccessKey.trim() !== "");
|
||||
outputMode !== "s3" || outputS3.connectionId.trim() !== "";
|
||||
const outputValid =
|
||||
(outputMode !== "folder" || outputDirectory.trim() !== "") && s3OutputValid;
|
||||
const canSave =
|
||||
@@ -398,7 +377,7 @@ export function PipelineBuilder() {
|
||||
}
|
||||
|
||||
const listPath = toPortalPath(VIEW_PATHS.pipelines);
|
||||
const sourcesPath = `${toPortalPath(VIEW_PATHS.sources)}?new=1`;
|
||||
const sourcesPath = `${toPortalPath(VIEW_PATHS.sources)}/new`;
|
||||
|
||||
function close() {
|
||||
navigate(listPath);
|
||||
@@ -774,25 +753,31 @@ export function PipelineBuilder() {
|
||||
</FormField>
|
||||
)}
|
||||
{outputMode === "s3" && (
|
||||
<div className="portal-builder__s3-output">
|
||||
<span
|
||||
className={
|
||||
"portal-builder__s3-summary" +
|
||||
(outputS3.bucket ? "" : " is-unset")
|
||||
<>
|
||||
<FormField
|
||||
label={t("portal.sources.types.s3.fields.connection.label")}
|
||||
required
|
||||
>
|
||||
<S3ConnectionPicker
|
||||
value={outputS3.connectionId}
|
||||
onChange={(connectionId) =>
|
||||
setOutputS3((s) => ({ ...s, connectionId }))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t("portal.sources.types.s3.fields.prefix.label")}
|
||||
helperText={t("portal.pipelines.composer.s3PrefixHelp")}
|
||||
>
|
||||
{outputS3.bucket
|
||||
? `s3://${outputS3.bucket}/${outputS3.prefix}`
|
||||
: t("portal.pipelines.composer.s3NotConfigured")}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setS3ConfigOpen(true)}
|
||||
>
|
||||
{t("portal.pipelines.composer.s3Configure")}
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={outputS3.prefix}
|
||||
placeholder="processed/"
|
||||
onChange={(e) =>
|
||||
setOutputS3((s) => ({ ...s, prefix: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1006,78 +991,6 @@ export function PipelineBuilder() {
|
||||
>
|
||||
<p>{t("portal.pipelines.builder.unsavedBody")}</p>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={s3ConfigOpen}
|
||||
onClose={() => setS3ConfigOpen(false)}
|
||||
title={t("portal.pipelines.composer.s3ModalTitle")}
|
||||
footer={
|
||||
<div className="portal-pipelines__composer-footer">
|
||||
<Button size="sm" onClick={() => setS3ConfigOpen(false)}>
|
||||
{t("portal.pipelines.composer.s3Done")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="portal-builder__s3-fields">
|
||||
<FormField
|
||||
label={t("portal.sources.types.s3.fields.bucket.label")}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
value={outputS3.bucket}
|
||||
placeholder="my-company-inbox"
|
||||
onChange={(e) => setS3Field("bucket", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("portal.sources.types.s3.fields.region.label")}>
|
||||
<Input
|
||||
value={outputS3.region}
|
||||
placeholder="us-east-1"
|
||||
onChange={(e) => setS3Field("region", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t("portal.sources.types.s3.fields.prefix.label")}
|
||||
helperText={t("portal.pipelines.composer.s3PrefixHelp")}
|
||||
>
|
||||
<Input
|
||||
value={outputS3.prefix}
|
||||
placeholder="processed/"
|
||||
onChange={(e) => setS3Field("prefix", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t("portal.sources.types.s3.fields.accessKeyId.label")}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
value={outputS3.accessKeyId}
|
||||
onChange={(e) => setS3Field("accessKeyId", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t("portal.sources.types.s3.fields.secretAccessKey.label")}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={outputS3.secretAccessKey}
|
||||
onChange={(e) => setS3Field("secretAccessKey", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t("portal.sources.types.s3.fields.endpoint.label")}
|
||||
helperText={t("portal.sources.types.s3.fields.endpoint.helperText")}
|
||||
>
|
||||
<Input
|
||||
value={outputS3.endpoint}
|
||||
placeholder="https://s3.example.com"
|
||||
onChange={(e) => setS3Field("endpoint", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function Pipelines() {
|
||||
const openCreate = () =>
|
||||
navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/new`);
|
||||
const connectSource = () =>
|
||||
navigate(`${toPortalPath(VIEW_PATHS.sources)}?new`);
|
||||
navigate(`${toPortalPath(VIEW_PATHS.sources)}/new`);
|
||||
// A row opens that pipeline's own page (view / edit / run / delete live there).
|
||||
const openPipeline = (pipeline: PipelineView) =>
|
||||
navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/${pipeline.id}`);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
.portal-source-builder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
padding: 1.5rem;
|
||||
max-width: 84rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.portal-source-builder__loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.portal-source-builder__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.portal-source-builder__head-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.portal-source-builder__title {
|
||||
font-size: 1.375rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.portal-source-builder__head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.portal-source-builder__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-width: 32rem;
|
||||
}
|
||||
|
||||
.portal-source-builder__type-grid {
|
||||
display: flex;
|
||||
gap: 0.625rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.portal-source-builder__type-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
min-width: 6rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-source-builder__type-card.is-selected {
|
||||
border-color: var(--color-accent, var(--color-brand));
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.portal-source-builder__type-icon {
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.portal-source-builder__type-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.portal-source-builder__delete-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fireEvent,
|
||||
render as baseRender,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { SourceBuilder } from "@portal/views/SourceBuilder";
|
||||
|
||||
const render = (ui: Parameters<typeof baseRender>[0]) =>
|
||||
baseRender(ui, { wrapper: MantineProvider });
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { changeLanguage: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
const createSource = vi.fn();
|
||||
const fetchSource = vi.fn();
|
||||
const deleteSource = vi.fn();
|
||||
vi.mock("@portal/api/sources", () => ({
|
||||
createSource: (s: unknown) => createSource(s),
|
||||
fetchSource: (id: string) => fetchSource(id),
|
||||
deleteSource: (id: string) => deleteSource(id),
|
||||
}));
|
||||
|
||||
const fetchS3Connections = vi.fn();
|
||||
vi.mock("@portal/api/integrations", () => ({
|
||||
fetchS3Connections: () => fetchS3Connections(),
|
||||
createIntegration: vi.fn(),
|
||||
}));
|
||||
|
||||
function renderBuilder(initial: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initial]}>
|
||||
<Routes>
|
||||
<Route path="/processor/sources" element={<div>sources list</div>} />
|
||||
<Route path="/processor/sources/new" element={<SourceBuilder />} />
|
||||
<Route path="/processor/sources/:id" element={<SourceBuilder />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("SourceBuilder", () => {
|
||||
beforeEach(() => {
|
||||
createSource.mockReset();
|
||||
createSource.mockResolvedValue({ id: "src-1" });
|
||||
fetchSource.mockReset();
|
||||
deleteSource.mockReset();
|
||||
deleteSource.mockResolvedValue(undefined);
|
||||
fetchS3Connections.mockReset();
|
||||
fetchS3Connections.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("creates a folder source and returns to the list", async () => {
|
||||
renderBuilder("/processor/sources/new");
|
||||
|
||||
// Folder is the first offered type; fill name + directory.
|
||||
fireEvent.change(screen.getByLabelText(/portal\.sources\.wizard\.name/), {
|
||||
target: { value: "Claims intake" },
|
||||
});
|
||||
fireEvent.change(
|
||||
screen.getByLabelText(
|
||||
/portal\.sources\.types\.folder\.fields\.directory\.label/,
|
||||
),
|
||||
{ target: { value: "/data/incoming" } },
|
||||
);
|
||||
fireEvent.click(screen.getByText("portal.sources.builder.create"));
|
||||
|
||||
await waitFor(() => expect(createSource).toHaveBeenCalledTimes(1));
|
||||
expect(createSource).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "Claims intake",
|
||||
type: "folder",
|
||||
options: expect.objectContaining({ directory: "/data/incoming" }),
|
||||
enabled: true,
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByText("sources list")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gates the s3 type on a chosen connection", async () => {
|
||||
renderBuilder("/processor/sources/new");
|
||||
fireEvent.change(screen.getByLabelText(/portal\.sources\.wizard\.name/), {
|
||||
target: { value: "Bucket source" },
|
||||
});
|
||||
// Switch to the S3 type: the connection field appears and Create stays
|
||||
// disabled until a connection is chosen (connectionId is required).
|
||||
fireEvent.click(screen.getByText("portal.sources.types.s3.label"));
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"portal.sources.types.s3.fields.connection.label",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("portal.sources.builder.create").closest("button"),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("blocks create until required fields are filled", async () => {
|
||||
renderBuilder("/processor/sources/new");
|
||||
// Name given but directory (required) still blank -> Create disabled.
|
||||
fireEvent.change(screen.getByLabelText(/portal\.sources\.wizard\.name/), {
|
||||
target: { value: "Nameonly" },
|
||||
});
|
||||
expect(
|
||||
screen.getByText("portal.sources.builder.create").closest("button"),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("edits an existing source prefilled and saves with its id", async () => {
|
||||
fetchSource.mockResolvedValue({
|
||||
id: "src-9",
|
||||
name: "Existing",
|
||||
type: "folder",
|
||||
options: { directory: "/old", mode: "consume" },
|
||||
enabled: true,
|
||||
});
|
||||
renderBuilder("/processor/sources/src-9");
|
||||
|
||||
const directory = await screen.findByLabelText(
|
||||
/portal\.sources\.types\.folder\.fields\.directory\.label/,
|
||||
);
|
||||
expect((directory as HTMLInputElement).value).toBe("/old");
|
||||
fireEvent.change(directory, { target: { value: "/new" } });
|
||||
fireEvent.click(screen.getByText("portal.sources.builder.save"));
|
||||
|
||||
await waitFor(() => expect(createSource).toHaveBeenCalledTimes(1));
|
||||
expect(createSource).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "src-9",
|
||||
options: expect.objectContaining({ directory: "/new" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes an existing source after confirmation", async () => {
|
||||
fetchSource.mockResolvedValue({
|
||||
id: "src-9",
|
||||
name: "Existing",
|
||||
type: "folder",
|
||||
options: { directory: "/old" },
|
||||
enabled: true,
|
||||
});
|
||||
renderBuilder("/processor/sources/src-9");
|
||||
|
||||
fireEvent.click(await screen.findByText("portal.sources.builder.delete"));
|
||||
fireEvent.click(await screen.findByText("portal.sources.delete.confirm"));
|
||||
|
||||
await waitFor(() => expect(deleteSource).toHaveBeenCalledWith("src-9"));
|
||||
expect(await screen.findByText("sources list")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,326 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
|
||||
import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Checkbox,
|
||||
FormField,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Spinner,
|
||||
} from "@app/ui";
|
||||
import { errorMessage } from "@portal/api/http";
|
||||
import {
|
||||
createSource,
|
||||
deleteSource,
|
||||
fetchSource,
|
||||
type Source,
|
||||
} from "@portal/api/sources";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
|
||||
import { creatableSourceTypes } from "@portal/components/sources/creatableSourceTypes";
|
||||
import {
|
||||
CREATABLE_SOURCE_TYPES,
|
||||
defaultOptions,
|
||||
sourceTypeMeta,
|
||||
type CreatableSourceType,
|
||||
} from "@portal/components/sources/sourceTypes";
|
||||
import { S3ConnectionPicker } from "@portal/components/sources/S3ConnectionPicker";
|
||||
import "@portal/views/SourceBuilder.css";
|
||||
|
||||
const OFFERED_TYPES = creatableSourceTypes();
|
||||
|
||||
/** A source's stored type resolved to its create-form metadata (edit falls back to any type). */
|
||||
function typeFor(type: string | undefined): CreatableSourceType {
|
||||
return (
|
||||
CREATABLE_SOURCE_TYPES.find((t) => t.type === type) ??
|
||||
OFFERED_TYPES[0] ??
|
||||
CREATABLE_SOURCE_TYPES[0]
|
||||
);
|
||||
}
|
||||
|
||||
/** Stored options coerced to form strings, 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-page create/edit for a source, mirroring the pipeline builder: new lands
|
||||
* on /sources/new (with a type picker), a row opens /sources/:id prefilled.
|
||||
* Save and delete navigate back to the Sources list. The virtual editor source
|
||||
* is never routed here (the list row is not a link).
|
||||
*/
|
||||
export function SourceBuilder() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const isEdit = Boolean(id);
|
||||
const listPath = toPortalPath(VIEW_PATHS.sources);
|
||||
|
||||
const sourceState = useAsync<Source | null>(
|
||||
async () => (id ? await fetchSource(id) : null),
|
||||
[id],
|
||||
);
|
||||
|
||||
const [type, setType] = useState<CreatableSourceType>(OFFERED_TYPES[0]);
|
||||
const [name, setName] = useState("");
|
||||
const [options, setOptions] = useState<Record<string, string>>(() =>
|
||||
defaultOptions(OFFERED_TYPES[0]),
|
||||
);
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [seeded, setSeeded] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
// Seed once: immediately for a new source, or after the record loads for edit.
|
||||
useEffect(() => {
|
||||
if (seeded) return;
|
||||
if (isEdit && !sourceState.data) return;
|
||||
const source = sourceState.data ?? undefined;
|
||||
const resolved = typeFor(source?.type);
|
||||
setType(resolved);
|
||||
setName(source?.name ?? "");
|
||||
setOptions(optionsFor(resolved, source?.options));
|
||||
setEnabled(source?.enabled ?? true);
|
||||
setSeeded(true);
|
||||
}, [isEdit, sourceState.data, seeded]);
|
||||
|
||||
function chooseType(next: CreatableSourceType) {
|
||||
setType(next);
|
||||
setOptions(defaultOptions(next));
|
||||
}
|
||||
|
||||
function setOption(key: string, value: string) {
|
||||
setOptions((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
const requiredComplete = type.fields.every(
|
||||
(field) => !field.required || (options[field.key] ?? "").trim() !== "",
|
||||
);
|
||||
const canSave = name.trim() !== "" && requiredComplete && !submitting;
|
||||
|
||||
async function save() {
|
||||
if (!canSave) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await createSource({
|
||||
id: isEdit ? id : undefined,
|
||||
name: name.trim(),
|
||||
type: type.type,
|
||||
options,
|
||||
enabled,
|
||||
});
|
||||
navigate(listPath);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!id || deleting) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteSource(id);
|
||||
navigate(listPath);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
setDeleting(false);
|
||||
setPendingDelete(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isEdit && sourceState.error) {
|
||||
return (
|
||||
<div className="portal-source-builder">
|
||||
<Banner tone="danger" description={errorMessage(sourceState.error)} />
|
||||
<Button variant="tertiary" onClick={() => navigate(listPath)}>
|
||||
{t("portal.sources.builder.back")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEdit && !seeded) {
|
||||
return (
|
||||
<div className="portal-source-builder__loading">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="portal-source-builder">
|
||||
<header className="portal-source-builder__head">
|
||||
<div className="portal-source-builder__head-main">
|
||||
<Button
|
||||
variant="quiet"
|
||||
size="sm"
|
||||
onClick={() => navigate(listPath)}
|
||||
leftSection={
|
||||
<ArrowBackRoundedIcon style={{ fontSize: "1.125rem" }} />
|
||||
}
|
||||
>
|
||||
{t("portal.sources.builder.back")}
|
||||
</Button>
|
||||
<h1 className="portal-source-builder__title">
|
||||
{isEdit
|
||||
? name || t("portal.sources.builder.editTitle")
|
||||
: t("portal.sources.builder.createTitle")}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="portal-source-builder__head-actions">
|
||||
<Checkbox
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
label={t("portal.sources.builder.enabled")}
|
||||
/>
|
||||
{isEdit && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
accent="danger"
|
||||
onClick={() => setPendingDelete(true)}
|
||||
leftSection={
|
||||
<DeleteOutlineRoundedIcon style={{ fontSize: "1.125rem" }} />
|
||||
}
|
||||
>
|
||||
{t("portal.sources.builder.delete")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => navigate(listPath)}
|
||||
>
|
||||
{t("portal.sources.builder.cancel")}
|
||||
</Button>
|
||||
<Button size="sm" disabled={!canSave} onClick={() => void save()}>
|
||||
{isEdit
|
||||
? t("portal.sources.builder.save")
|
||||
: t("portal.sources.builder.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="portal-source-builder__body">
|
||||
<FormField label={t("portal.sources.wizard.name")} required>
|
||||
<Input
|
||||
value={name}
|
||||
placeholder={t("portal.sources.wizard.namePlaceholder")}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{!isEdit && OFFERED_TYPES.length > 1 && (
|
||||
<FormField label={t("portal.sources.wizard.type")}>
|
||||
<div className="portal-source-builder__type-grid">
|
||||
{OFFERED_TYPES.map((ct) => (
|
||||
<Button
|
||||
key={ct.type}
|
||||
variant="tertiary"
|
||||
className={
|
||||
"portal-source-builder__type-card" +
|
||||
(type.type === ct.type ? " is-selected" : "")
|
||||
}
|
||||
onClick={() => chooseType(ct)}
|
||||
>
|
||||
<span
|
||||
className="portal-source-builder__type-icon"
|
||||
aria-hidden
|
||||
>
|
||||
{sourceTypeMeta(ct.type).icon}
|
||||
</span>
|
||||
<span className="portal-source-builder__type-name">
|
||||
{t(ct.labelKey)}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</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 === "s3Connection" ? (
|
||||
<S3ConnectionPicker
|
||||
value={options[field.key] ?? ""}
|
||||
onChange={(connectionId) => setOption(field.key, connectionId)}
|
||||
/>
|
||||
) : field.control === "select" ? (
|
||||
<Select
|
||||
value={options[field.key] ?? ""}
|
||||
options={(field.options ?? []).map((o) => ({
|
||||
value: o.value,
|
||||
label: t(o.labelKey),
|
||||
}))}
|
||||
onChange={(value) => setOption(field.key, value ?? "")}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={field.control === "password" ? "password" : undefined}
|
||||
value={options[field.key] ?? ""}
|
||||
placeholder={
|
||||
field.placeholderKey ? t(field.placeholderKey) : undefined
|
||||
}
|
||||
onChange={(e) => setOption(field.key, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
))}
|
||||
|
||||
{error && <Banner tone="danger" description={error} />}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={pendingDelete}
|
||||
onClose={() => !deleting && setPendingDelete(false)}
|
||||
width="sm"
|
||||
title={t("portal.sources.delete.title")}
|
||||
footer={
|
||||
<div className="portal-source-builder__delete-actions">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
disabled={deleting}
|
||||
onClick={() => setPendingDelete(false)}
|
||||
>
|
||||
{t("portal.sources.delete.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
accent="danger"
|
||||
loading={deleting}
|
||||
onClick={() => void confirmDelete()}
|
||||
>
|
||||
{t("portal.sources.delete.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p>{t("portal.sources.delete.body", { name })}</p>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -438,3 +438,101 @@
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.portal-sources__connection-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.portal-sources__connection-picker .sui-select,
|
||||
.portal-sources__connection-picker > div:first-child {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.portal-sources__connection-create {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 0.5rem;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.portal-sources__connection-create-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-sources__connection-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.portal-sources__connections {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.portal-sources__connections-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-2);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.portal-sources__connections-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.portal-sources__connections-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.375rem 0;
|
||||
border-bottom: 1px solid var(--color-border-2);
|
||||
}
|
||||
|
||||
.portal-sources__connections-name {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-sources__connections-bucket {
|
||||
color: var(--color-text-4);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.portal-sources__connections-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.portal-sources__connections-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-sources__connections-sub {
|
||||
color: var(--color-text-4);
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.portal-sources__connections-actions {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@@ -3,21 +3,16 @@ import {
|
||||
fireEvent,
|
||||
render as baseRender,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { HttpError } from "@portal/api/http";
|
||||
|
||||
const render = (
|
||||
ui: Parameters<typeof baseRender>[0],
|
||||
options?: Parameters<typeof baseRender>[1],
|
||||
) => baseRender(ui, { wrapper: MantineProvider, ...options });
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
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.
|
||||
const render = (ui: Parameters<typeof baseRender>[0]) =>
|
||||
baseRender(ui, { wrapper: MantineProvider });
|
||||
|
||||
// Deterministic i18n: keys returned verbatim.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
@@ -26,48 +21,48 @@ vi.mock("react-i18next", () => ({
|
||||
}));
|
||||
|
||||
const fetchSources = vi.fn();
|
||||
const fetchSource = vi.fn();
|
||||
const fetchSourceDocCounts = vi.fn();
|
||||
const createSource = vi.fn();
|
||||
const deleteSource = vi.fn();
|
||||
vi.mock("@portal/api/sources", () => ({
|
||||
fetchSources: () => fetchSources(),
|
||||
fetchSource: (id: string) => fetchSource(id),
|
||||
fetchSourceDocCounts: (id: string) => fetchSourceDocCounts(id),
|
||||
createSource: (source: unknown) => createSource(source),
|
||||
deleteSource: (id: string) => deleteSource(id),
|
||||
}));
|
||||
|
||||
const fetchS3Connections = vi.fn();
|
||||
vi.mock("@portal/api/integrations", () => ({
|
||||
fetchS3Connections: () => fetchS3Connections(),
|
||||
deleteIntegration: vi.fn(),
|
||||
}));
|
||||
|
||||
// The Agent Builder header action is a flavor seam; stub it to keep the test focused.
|
||||
vi.mock("@portal/components/sources/AgentBuilderAction", () => ({
|
||||
AgentBuilderAction: () => null,
|
||||
}));
|
||||
|
||||
const RESPONSE: SourcesResponse = {
|
||||
kpis: [
|
||||
{ value: 2, description: "" },
|
||||
{ value: 1, description: "" },
|
||||
{ value: 1, description: "" },
|
||||
{ value: 0, description: "" },
|
||||
],
|
||||
sources: [
|
||||
{
|
||||
id: "src-referenced",
|
||||
id: "editor",
|
||||
name: "Editor",
|
||||
type: "editor",
|
||||
status: "active",
|
||||
referenceCount: 0,
|
||||
referencingPolicies: [],
|
||||
config: [],
|
||||
docsTotal: 5,
|
||||
docs24h: 0,
|
||||
docs30d: 5,
|
||||
},
|
||||
{
|
||||
id: "src-1",
|
||||
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: 1240,
|
||||
docs24h: 18,
|
||||
docs30d: 540,
|
||||
},
|
||||
{
|
||||
id: "src-orphan",
|
||||
name: "Scratch folder",
|
||||
type: "folder",
|
||||
status: "unused",
|
||||
referenceCount: 0,
|
||||
referencingPolicies: [],
|
||||
config: [{ label: "Directory", value: "/tmp/scratch" }],
|
||||
config: [{ label: "Directory", value: "/in" }],
|
||||
docsTotal: 1240,
|
||||
docs24h: 18,
|
||||
docs30d: 540,
|
||||
@@ -75,10 +70,20 @@ const RESPONSE: SourcesResponse = {
|
||||
],
|
||||
};
|
||||
|
||||
function renderView() {
|
||||
function renderView(initial = "/processor/sources") {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<Sources />
|
||||
<MemoryRouter initialEntries={[initial]}>
|
||||
<Routes>
|
||||
<Route path="/processor/sources" element={<Sources />} />
|
||||
<Route
|
||||
path="/processor/sources/new"
|
||||
element={<div>source builder: new</div>}
|
||||
/>
|
||||
<Route
|
||||
path="/processor/sources/:id"
|
||||
element={<div>source builder: edit</div>}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
@@ -86,122 +91,54 @@ function renderView() {
|
||||
describe("Sources view", () => {
|
||||
beforeEach(() => {
|
||||
fetchSources.mockReset();
|
||||
fetchSource.mockReset();
|
||||
fetchSourceDocCounts.mockReset();
|
||||
fetchSourceDocCounts.mockResolvedValue([]);
|
||||
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("portal.sources.detail.delete"));
|
||||
|
||||
// Confirm in the dialog.
|
||||
fireEvent.click(await screen.findByText("portal.sources.delete.confirm"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteSource).toHaveBeenCalledWith("src-referenced");
|
||||
fetchS3Connections.mockReset();
|
||||
fetchS3Connections.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText("Source is referenced by 2 policies"),
|
||||
).toBeInTheDocument();
|
||||
it("opens a source's own page on row click", async () => {
|
||||
renderView();
|
||||
fireEvent.click(await screen.findByText("Claims intake"));
|
||||
expect(await screen.findByText("source builder: edit")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the editor as a built-in source with no edit, pause, or delete actions", async () => {
|
||||
fetchSources.mockResolvedValue({
|
||||
kpis: [],
|
||||
sources: [
|
||||
{
|
||||
id: "editor",
|
||||
name: "Editor",
|
||||
type: "editor",
|
||||
status: "active",
|
||||
referenceCount: 1,
|
||||
referencingPolicies: [{ id: "pol-1", name: "Redaction" }],
|
||||
config: [],
|
||||
docsTotal: 8230,
|
||||
docs24h: 42,
|
||||
docs30d: 1680,
|
||||
},
|
||||
],
|
||||
} satisfies SourcesResponse);
|
||||
|
||||
it("navigates to the create page from the connect button", async () => {
|
||||
renderView();
|
||||
await screen.findByText("Claims intake");
|
||||
fireEvent.click(screen.getByText("portal.sources.actions.connectSource"));
|
||||
expect(await screen.findByText("source builder: new")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The editor row is labelled from its type (i18n keys are returned verbatim here).
|
||||
it("does not navigate when the virtual editor row is clicked", async () => {
|
||||
renderView();
|
||||
fireEvent.click(
|
||||
await screen.findByText("portal.sources.types.editor.label"),
|
||||
);
|
||||
|
||||
// Detail opens, but none of the mutate actions are offered for the built-in source.
|
||||
await screen.findByText("portal.sources.detail.documents");
|
||||
expect(screen.queryByText("portal.sources.detail.edit")).toBeNull();
|
||||
expect(screen.queryByText("portal.sources.detail.pause")).toBeNull();
|
||||
expect(screen.queryByText("portal.sources.detail.delete")).toBeNull();
|
||||
// Still on the list: the builder stub never rendered.
|
||||
expect(screen.queryByText("source builder: edit")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Claims intake")).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("portal.sources.detail.pause"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createSource).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(fetchSource).toHaveBeenCalledWith("src-referenced");
|
||||
expect(createSource).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "src-referenced", enabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the KPI stat boxes when sources exist", async () => {
|
||||
fetchSources.mockResolvedValue(RESPONSE);
|
||||
it("shows the connections surface on the Connections tab", async () => {
|
||||
renderView();
|
||||
await screen.findByText("Claims intake");
|
||||
expect(screen.getByText("portal.sources.kpi.total")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("portal.sources.tabs.connections"));
|
||||
// Empty connections list -> the connections empty state.
|
||||
expect(
|
||||
await screen.findByText("portal.connections.empty.title"),
|
||||
).toBeInTheDocument();
|
||||
expect(fetchS3Connections).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides the stat boxes and shows the connect CTA when empty", async () => {
|
||||
it("hides the KPI strip and shows the empty state when only the editor exists", async () => {
|
||||
fetchSources.mockResolvedValue({
|
||||
kpis: [
|
||||
{ value: 0, description: "" },
|
||||
{ value: 0, description: "" },
|
||||
{ value: 0, description: "" },
|
||||
],
|
||||
sources: [],
|
||||
kpis: RESPONSE.kpis,
|
||||
sources: [RESPONSE.sources[0]],
|
||||
});
|
||||
renderView();
|
||||
// The empty-state panel renders.
|
||||
expect(
|
||||
await screen.findByText("portal.sources.empty.title"),
|
||||
).toBeInTheDocument();
|
||||
// The KPI strip is gone: no stat-box labels over an empty page.
|
||||
expect(
|
||||
screen.queryByText("portal.sources.kpi.total"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
@@ -1,137 +1,49 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button, EmptyState, Modal, Skeleton } from "@app/ui";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import AddRoundedIcon from "@mui/icons-material/AddRounded";
|
||||
import { Button, EmptyState, Skeleton, Tabs } from "@app/ui";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
import { SourcesIcon } from "@portal/components/icons";
|
||||
import { errorMessage } from "@portal/api/http";
|
||||
import {
|
||||
createSource,
|
||||
deleteSource,
|
||||
fetchSource,
|
||||
fetchSourceDocCounts,
|
||||
fetchSources,
|
||||
type Source,
|
||||
type SourcesResponse,
|
||||
type SourceView,
|
||||
} from "@portal/api/sources";
|
||||
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
|
||||
import { AgentBuilderAction } from "@portal/components/sources/AgentBuilderAction";
|
||||
import { KpiStrip } from "@portal/components/sources/KpiStrip";
|
||||
import { SourcesTable } from "@portal/components/sources/SourcesTable";
|
||||
import { SourceDetailCard } from "@portal/components/sources/SourceDetailCard";
|
||||
import { ConnectWizard } from "@portal/components/sources/ConnectWizard";
|
||||
import { ConnectionsTab } from "@portal/components/sources/ConnectionsTab";
|
||||
import "@portal/views/Sources.css";
|
||||
|
||||
type SourcesTab = "sources" | "connections";
|
||||
|
||||
export function Sources() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// 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 activeTab: SourcesTab =
|
||||
searchParams.get("tab") === "connections" ? "connections" : "sources";
|
||||
|
||||
const state = useAsync<SourcesResponse>(() => fetchSources(), []);
|
||||
const { data, loading } = state;
|
||||
const { isLoading } = 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;
|
||||
// Empty once the fetch settles with no sources (or fails → no data). Gates
|
||||
// both the KPI strip and the empty panel so no placeholder stat boxes sit
|
||||
// above an empty page.
|
||||
const showEmpty = !isLoading && sources.length === 0;
|
||||
// The editor is a virtual row that's always present, so "empty" means no
|
||||
// configured sources beyond it. Gates the KPI strip and empty panel.
|
||||
const configuredCount = sources.filter((s) => s.type !== "editor").length;
|
||||
const showEmpty = !isLoading && configuredCount === 0;
|
||||
|
||||
// The 30-day sparkline series lives off the list endpoint; fetch it for the one
|
||||
// expanded row only (empty while collapsed, so no request fires).
|
||||
const docSeriesState = useAsync<{ id: string; series: number[] }>(
|
||||
() =>
|
||||
expandedId
|
||||
? fetchSourceDocCounts(expandedId).then((series) => ({
|
||||
id: expandedId,
|
||||
series,
|
||||
}))
|
||||
: Promise.resolve({ id: "", series: [] }),
|
||||
[expandedId],
|
||||
);
|
||||
const docSeries =
|
||||
docSeriesState.data?.id === expandedId ? docSeriesState.data.series : [];
|
||||
const openCreate = () => navigate(`${toPortalPath(VIEW_PATHS.sources)}/new`);
|
||||
const openSource = (source: SourceView) =>
|
||||
navigate(`${toPortalPath(VIEW_PATHS.sources)}/${source.id}`);
|
||||
|
||||
function openCreate() {
|
||||
setEditingSource(null);
|
||||
setWizardOpen(true);
|
||||
}
|
||||
|
||||
// Arriving with ?new (e.g. from the pipeline builder's "connect a source" link) opens the
|
||||
// create wizard straight away, then strips the flag so a refresh doesn't reopen it.
|
||||
useEffect(() => {
|
||||
if (searchParams.get("new") === null) return;
|
||||
setEditingSource(null);
|
||||
setWizardOpen(true);
|
||||
function selectTab(tab: SourcesTab) {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("new");
|
||||
if (tab === "sources") next.delete("tab");
|
||||
else next.set("tab", tab);
|
||||
setSearchParams(next, { replace: true });
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
// 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 (
|
||||
@@ -141,16 +53,34 @@ export function Sources() {
|
||||
<h1 className="portal-sources__title">{t("portal.sources.title")}</h1>
|
||||
<p className="portal-sources__sub">{t("portal.sources.subtitle")}</p>
|
||||
</div>
|
||||
{activeTab === "sources" && (
|
||||
<div className="portal-sources__actions">
|
||||
<AgentBuilderAction />
|
||||
<Button onClick={openCreate} leftSection={<span aria-hidden>+</span>}>
|
||||
<Button
|
||||
onClick={openCreate}
|
||||
leftSection={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}
|
||||
>
|
||||
{t("portal.sources.actions.connectSource")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{pageError && <Banner tone="danger" description={pageError} />}
|
||||
<Tabs<SourcesTab>
|
||||
variant="underline"
|
||||
ariaLabel={t("portal.sources.title")}
|
||||
activeKey={activeTab}
|
||||
onChange={selectTab}
|
||||
items={[
|
||||
{ key: "sources", label: t("portal.sources.tabs.sources") },
|
||||
{ key: "connections", label: t("portal.sources.tabs.connections") },
|
||||
]}
|
||||
/>
|
||||
|
||||
{activeTab === "connections" ? (
|
||||
<ConnectionsTab />
|
||||
) : (
|
||||
<>
|
||||
{!showEmpty && <KpiStrip data={data} loading={loading} />}
|
||||
|
||||
{isLoading && (
|
||||
@@ -169,7 +99,9 @@ export function Sources() {
|
||||
actions={
|
||||
<Button
|
||||
onClick={openCreate}
|
||||
leftSection={<span aria-hidden>+</span>}
|
||||
leftSection={
|
||||
<AddRoundedIcon style={{ fontSize: "1.125rem" }} />
|
||||
}
|
||||
>
|
||||
{t("portal.sources.actions.connectSource")}
|
||||
</Button>
|
||||
@@ -178,65 +110,10 @@ export function Sources() {
|
||||
)}
|
||||
|
||||
{!isLoading && sources.length > 0 && (
|
||||
<SourcesTable
|
||||
sources={sources}
|
||||
expandedId={expandedId}
|
||||
onRowClick={(s) =>
|
||||
setExpandedId((cur) => (cur === s.id ? null : s.id))
|
||||
}
|
||||
/>
|
||||
<SourcesTable sources={sources} onRowClick={openSource} />
|
||||
)}
|
||||
|
||||
{expanded && (
|
||||
<SourceDetailCard
|
||||
source={expanded}
|
||||
docSeries={docSeries}
|
||||
onClose={() => setExpandedId(null)}
|
||||
onEdit={openEdit}
|
||||
onTogglePause={togglePause}
|
||||
onDelete={requestDelete}
|
||||
busy={mutating}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ConnectWizard
|
||||
open={wizardOpen}
|
||||
source={editingSource ?? undefined}
|
||||
onClose={() => setWizardOpen(false)}
|
||||
onCreated={refetch}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={pendingDelete !== null}
|
||||
onClose={() => !deleting && setPendingDelete(null)}
|
||||
width="sm"
|
||||
title={t("portal.sources.delete.title")}
|
||||
footer={
|
||||
<div className="portal-sources__wizard-footer">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
disabled={deleting}
|
||||
onClick={() => setPendingDelete(null)}
|
||||
>
|
||||
{t("portal.sources.delete.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
accent="danger"
|
||||
loading={deleting}
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
{t("portal.sources.delete.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
{t("portal.sources.delete.body", { name: pendingDelete?.name ?? "" })}
|
||||
</p>
|
||||
{deleteError && <Banner tone="danger" description={deleteError} />}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user