Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
600add1474 | ||
|
|
8451b06d53 | ||
|
|
7bb3024dc9 | ||
|
|
18cd2baec0 |
+3
-2
@@ -157,7 +157,9 @@ app/proprietary/build
|
||||
common/build
|
||||
proprietary/build
|
||||
stirling-pdf/build
|
||||
frontend/editor/src-tauri/provisioner/target
|
||||
frontend/src-tauri/provisioner/target
|
||||
frontend/src-tauri/target
|
||||
frontend/src-tauri/runtime/
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
@@ -174,7 +176,6 @@ venv.bak/
|
||||
|
||||
# Env files (secrets / local overrides). Subproject .gitignore files whitelist any committed defaults.
|
||||
.env*
|
||||
!.env.saas.example
|
||||
|
||||
# VS Code
|
||||
/.vscode/**/*
|
||||
|
||||
@@ -25,9 +25,9 @@ tasks:
|
||||
env:
|
||||
SERVER_PORT: '{{.PORT}}'
|
||||
cmds:
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS=120 {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
platforms: [windows]
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS=120 {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
platforms: [linux, darwin]
|
||||
|
||||
dev:bundled:
|
||||
|
||||
@@ -257,6 +257,7 @@ tasks:
|
||||
- task: lint
|
||||
- task: format:check
|
||||
- task: test
|
||||
- task: theme:check-unused-vars
|
||||
|
||||
check:all:
|
||||
desc: "Full CI quality gate"
|
||||
@@ -298,3 +299,17 @@ tasks:
|
||||
deps: [install]
|
||||
cmds:
|
||||
- node editor/scripts/generate-licenses.js
|
||||
|
||||
theme:clean-unused-vars:
|
||||
desc: "Remove unused CSS variables from theme.css"
|
||||
deps: [install]
|
||||
vars:
|
||||
REMOVE: '{{.REMOVE | default "false"}}'
|
||||
cmds:
|
||||
- node editor/scripts/clean-unused-theme-vars.js{{if eq .REMOVE "true"}} --remove{{end}} {{.CLI_ARGS}}
|
||||
|
||||
theme:check-unused-vars:
|
||||
desc: "Fail if theme.css contains unused CSS variables"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- node editor/scripts/clean-unused-theme-vars.js --check
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ tasks:
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
|
||||
- task: frontend:dev:prototypes
|
||||
- task: frontend:dev
|
||||
vars:
|
||||
PORT: '{{.FRONTEND_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
|
||||
@@ -78,9 +78,6 @@ dependencies {
|
||||
runtimeOnly "com.stirling:jpdfium-natives-${platform}:1.0.1"
|
||||
}
|
||||
|
||||
// Bucket4j (local in-process token bucket for RateLimitStore default impl)
|
||||
implementation 'com.bucket4j:bucket4j_jdk17-core:8.19.0'
|
||||
|
||||
// ArchUnit: enforces module dependency direction (see ArchitectureTest)
|
||||
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2'
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package stirling.software.common.cluster;
|
||||
|
||||
/** Health and identity facade for the active cluster backplane. */
|
||||
public interface ClusterBackplane {
|
||||
|
||||
/** Returns {@code true} when the backplane is reachable; used for health endpoints. */
|
||||
boolean isHealthy();
|
||||
|
||||
/** Returns {@code "inprocess"} or {@code "valkey"}. */
|
||||
String backplaneType();
|
||||
|
||||
/** Returns this JVM's stable node id (matches {@code Cluster.resolvedNodeId()}). */
|
||||
String localNodeId();
|
||||
|
||||
/**
|
||||
* Whether this JVM should run the local {@link
|
||||
* stirling.software.common.service.TaskManager#cleanupOldJobs()} loop. Distributed backplanes
|
||||
* own job expiry via their own TTL, so they should override this to return {@code false}.
|
||||
* Defaults to {@code true} so in-process behavior is preserved without an explicit override.
|
||||
*/
|
||||
default boolean shouldRunLocalCleanup() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package stirling.software.common.cluster;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.Cluster;
|
||||
|
||||
/**
|
||||
* Validates that cluster mode is internally consistent.
|
||||
*
|
||||
* <p>Cluster settings are bound on the central {@link ApplicationProperties} under {@code
|
||||
* cluster.*}; this class reads {@link ApplicationProperties#getCluster()} and runs guards in {@link
|
||||
* PostConstruct}. When {@code cluster.enabled=false} (the default) all checks are skipped so a
|
||||
* single-instance install needs no new config.
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class ClusterConfig {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@PostConstruct
|
||||
void validate() {
|
||||
Cluster cluster = applicationProperties.getCluster();
|
||||
if (!cluster.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
String backplane = cluster.getBackplane();
|
||||
if ("valkey".equalsIgnoreCase(backplane)) {
|
||||
String url = cluster.getValkey() == null ? null : cluster.getValkey().getUrl();
|
||||
if (url == null || url.isBlank()) {
|
||||
throw new IllegalStateException(
|
||||
"cluster.enabled=true with backplane=valkey requires"
|
||||
+ " cluster.valkey.url to be set (e.g."
|
||||
+ " redis://valkey:6379).");
|
||||
}
|
||||
} else if ("inprocess".equalsIgnoreCase(backplane)) {
|
||||
// enabled+inprocess only coordinates the local JVM; cross-node lookups will 410.
|
||||
log.warn(
|
||||
"cluster.enabled=true with backplane=inprocess - only the local"
|
||||
+ " JVM is coordinated. Cross-node lookups and the file proxy will fail."
|
||||
+ " Use backplane=valkey for real multi-node deployments.");
|
||||
} else {
|
||||
// Fail fast on typos like "valky" so Spring doesn't later report a cryptic
|
||||
// "no ClusterBackplane bean" - the operator-facing error names the bad value.
|
||||
throw new IllegalStateException(
|
||||
"cluster.enabled=true with unknown backplane '"
|
||||
+ backplane
|
||||
+ "'. Valid values: inprocess | valkey.");
|
||||
}
|
||||
log.info(
|
||||
"Cluster mode enabled (backplane={}, nodeRole={}, nodeId={}).",
|
||||
backplane,
|
||||
cluster.resolvedRole(),
|
||||
cluster.resolvedNodeId());
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package stirling.software.common.cluster;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Snapshot of a peer node as recorded in the {@link InstanceRegistry}.
|
||||
*
|
||||
* @param internalAddress {@code host:port} the node listens on for {@code /internal/cluster/**}
|
||||
* @param role one of {@code WEB}, {@code WORKER}, {@code BOTH}
|
||||
*/
|
||||
public record ClusterNode(
|
||||
String nodeId, String internalAddress, Instant lastHeartbeat, String role) {}
|
||||
@@ -1,21 +0,0 @@
|
||||
package stirling.software.common.cluster;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Cluster-wide mutual exclusion primitive; non-reentrant by contract. */
|
||||
public interface DistributedLock {
|
||||
|
||||
Optional<LockHandle> tryAcquire(String lockKey, Duration leaseTime);
|
||||
|
||||
interface LockHandle extends AutoCloseable {
|
||||
void release();
|
||||
|
||||
boolean renew(Duration leaseTime);
|
||||
|
||||
@Override
|
||||
default void close() {
|
||||
release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package stirling.software.common.cluster;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/** Low-level storage seam for result/job files. */
|
||||
public interface FileStore {
|
||||
|
||||
/** Stored file record. */
|
||||
record Stored(String fileId, long size) {}
|
||||
|
||||
/** Store the given stream and return a generated file id and total bytes written. */
|
||||
Stored store(InputStream in, String originalName) throws IOException;
|
||||
|
||||
/**
|
||||
* Store the file at {@code source} and return a generated file id and total bytes written.
|
||||
*
|
||||
* <p>Default implementation opens {@code source} as a stream and delegates to {@link
|
||||
* #store(InputStream, String)}. Local-disk implementations should override to use a direct
|
||||
* file-to-file copy ({@code Files.copy(source, dest)} can use {@code sendfile(2)} on Linux),
|
||||
* which avoids the two-memory-copy hit of streaming a disk-backed upload through the JVM heap.
|
||||
*/
|
||||
default Stored store(Path source, String originalName) throws IOException {
|
||||
try (InputStream in = Files.newInputStream(source)) {
|
||||
return store(in, originalName);
|
||||
}
|
||||
}
|
||||
|
||||
/** Open the stored file for streaming reads. Caller closes. */
|
||||
InputStream retrieve(String fileId) throws IOException;
|
||||
|
||||
/** Load the stored file into a byte array. */
|
||||
byte[] retrieveBytes(String fileId) throws IOException;
|
||||
|
||||
/** Size of the stored file in bytes. */
|
||||
long size(String fileId) throws IOException;
|
||||
|
||||
/** Delete the stored file. Returns true if a file was removed. */
|
||||
boolean delete(String fileId);
|
||||
|
||||
/** Whether the file id exists in the store. */
|
||||
boolean exists(String fileId);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package stirling.software.common.cluster;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Collection;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Maps {@code nodeId} to its internal cluster address, with TTL'd heartbeats. */
|
||||
public interface InstanceRegistry {
|
||||
|
||||
/** Register or refresh this node. Idempotent so a wiped backplane self-heals on next tick. */
|
||||
void register(ClusterNode node, Duration heartbeatTtl);
|
||||
|
||||
Optional<ClusterNode> lookup(String nodeId);
|
||||
|
||||
Collection<ClusterNode> activeNodes();
|
||||
|
||||
void deregister(String nodeId);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package stirling.software.common.cluster;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Collection;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Cluster-visible storage for job status and result metadata, with TTL'd entries. */
|
||||
public interface JobStore {
|
||||
|
||||
/** Persist or overwrite a job entry. {@code ttl} sets the lifetime of the entry. */
|
||||
void put(JobStoreEntry entry, Duration ttl);
|
||||
|
||||
Optional<JobStoreEntry> get(String jobId);
|
||||
|
||||
void delete(String jobId);
|
||||
|
||||
boolean exists(String jobId);
|
||||
|
||||
/** Reverse lookup: which job owns this result file id? */
|
||||
Optional<String> findJobIdByFileId(String fileId);
|
||||
|
||||
/** Snapshot of every active entry. Used by admin/stats endpoints; may be O(n). */
|
||||
Collection<JobStoreEntry> all();
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package stirling.software.common.cluster;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Cluster-visible projection of a job's status and result metadata, as persisted in {@link
|
||||
* JobStore}.
|
||||
*
|
||||
* @param owningNodeId the node id that originally executed the job
|
||||
*/
|
||||
public record JobStoreEntry(
|
||||
String jobId,
|
||||
JobState state,
|
||||
String owningNodeId,
|
||||
Instant createdAt,
|
||||
Instant completedAt,
|
||||
String error,
|
||||
List<String> fileIds,
|
||||
Map<String, String> resultMeta) {
|
||||
|
||||
/** Lifecycle states for a job as observed by the cluster. */
|
||||
public enum JobState {
|
||||
PENDING,
|
||||
RUNNING,
|
||||
COMPLETE,
|
||||
FAILED
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package stirling.software.common.cluster;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Short-TTL namespaced key/value cache backed by the cluster backplane. */
|
||||
public interface KeyValueCache {
|
||||
|
||||
void put(String namespace, String key, String value, Duration ttl);
|
||||
|
||||
Optional<String> get(String namespace, String key);
|
||||
|
||||
void evict(String namespace, String key);
|
||||
|
||||
void evictNamespace(String namespace);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package stirling.software.common.cluster;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/** Token-bucket rate limiting backed by the cluster backplane. */
|
||||
public interface RateLimitStore {
|
||||
|
||||
/**
|
||||
* Attempt to consume one token from the bucket identified by {@code bucketKey}.
|
||||
*
|
||||
* @param bucketKey opaque key identifying the bucket (e.g. {@code api:user:123})
|
||||
* @param capacity bucket capacity
|
||||
* @param refillPeriod time window over which {@code capacity} tokens refill
|
||||
*/
|
||||
RateLimitDecision tryConsume(String bucketKey, long capacity, Duration refillPeriod);
|
||||
|
||||
record RateLimitDecision(boolean allowed, long remainingTokens, long nanosToWaitForRefill) {}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package stirling.software.common.cluster;
|
||||
|
||||
/** Records one increment per sticky-session miss (a 410 Gone for a job owned by another node). */
|
||||
@FunctionalInterface
|
||||
public interface StickyMissRecorder {
|
||||
void recordStickyMiss();
|
||||
}
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
package stirling.software.common.cluster.inprocess;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.cluster.ClusterBackplane;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
@Slf4j
|
||||
public class InProcessClusterBackplane implements ClusterBackplane {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
public InProcessClusterBackplane(ApplicationProperties applicationProperties) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isHealthy() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String backplaneType() {
|
||||
return "inprocess";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String localNodeId() {
|
||||
return applicationProperties.getCluster().resolvedNodeId();
|
||||
}
|
||||
}
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
package stirling.software.common.cluster.inprocess;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.cluster.ClusterBackplane;
|
||||
import stirling.software.common.cluster.DistributedLock;
|
||||
import stirling.software.common.cluster.InstanceRegistry;
|
||||
import stirling.software.common.cluster.JobStore;
|
||||
import stirling.software.common.cluster.KeyValueCache;
|
||||
import stirling.software.common.cluster.RateLimitStore;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Default cluster backplane wiring: every interface gets an {@code InProcess*} bean. Active when
|
||||
* cluster mode is off or {@code cluster.backplane=inprocess}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@ConditionalOnExpression(
|
||||
"!${cluster.enabled:false} ||"
|
||||
+ " '${cluster.backplane:inprocess}'.equalsIgnoreCase('inprocess')")
|
||||
public class InProcessClusterConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public ClusterBackplane clusterBackplane(ApplicationProperties applicationProperties) {
|
||||
log.info("Cluster backplane: in-process (single node)");
|
||||
return new InProcessClusterBackplane(applicationProperties);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public JobStore jobStore() {
|
||||
return new InProcessJobStore();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public RateLimitStore rateLimitStore() {
|
||||
return new InProcessRateLimitStore();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public DistributedLock distributedLock() {
|
||||
return new InProcessDistributedLock();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public KeyValueCache keyValueCache() {
|
||||
return new InProcessKeyValueCache();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public InstanceRegistry instanceRegistry() {
|
||||
return new InProcessInstanceRegistry();
|
||||
}
|
||||
}
|
||||
-128
@@ -1,128 +0,0 @@
|
||||
package stirling.software.common.cluster.inprocess;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import stirling.software.common.cluster.DistributedLock;
|
||||
|
||||
/**
|
||||
* In-process {@link DistributedLock}, non-reentrant per the interface contract, with lease-expiry
|
||||
* semantics that mirror a SET-NX-EX style distributed backend.
|
||||
*
|
||||
* <p>Each lock state carries a per-acquire {@code ownerToken} and an {@code expiryNanos}; another
|
||||
* caller can take over once the lease has elapsed even if the original holder never called {@link
|
||||
* LockHandle#release()}. This matters mostly for parity with the Valkey-backed implementation
|
||||
* (Redis {@code SETEX} auto-expires the key); within a single JVM a crashed holder takes its lock
|
||||
* state with it, but tests and code that rely on the {@code leaseTime} parameter still need it to
|
||||
* be honored.
|
||||
*
|
||||
* <p>Expiry is lazy: an expired lock state lingers in the map until the next acquire attempt for
|
||||
* the same key replaces it. Per-key cleanup also happens on explicit {@link LockHandle#release()},
|
||||
* so a balanced acquire/release workload keeps the map size bounded.
|
||||
*/
|
||||
public class InProcessDistributedLock implements DistributedLock {
|
||||
|
||||
private final ConcurrentHashMap<String, LockState> locks = new ConcurrentHashMap<>();
|
||||
private final AtomicLong tokenSeq = new AtomicLong();
|
||||
|
||||
/**
|
||||
* Lease state for a single acquired lock. {@code ownerToken} prevents a former holder from
|
||||
* releasing or renewing a lock now owned by someone else after lease expiry; {@code
|
||||
* expiryNanos} is read/written only inside {@link ConcurrentHashMap#compute} so the bin lock
|
||||
* provides the necessary happens-before guarantee.
|
||||
*/
|
||||
private static final class LockState {
|
||||
final long ownerToken;
|
||||
long expiryNanos;
|
||||
|
||||
LockState(long ownerToken, long expiryNanos) {
|
||||
this.ownerToken = ownerToken;
|
||||
this.expiryNanos = expiryNanos;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<LockHandle> tryAcquire(String lockKey, Duration leaseTime) {
|
||||
long token = tokenSeq.incrementAndGet();
|
||||
long nowNanos = System.nanoTime();
|
||||
long expiryNanos = nowNanos + leaseTime.toNanos();
|
||||
boolean[] acquired = {false};
|
||||
locks.compute(
|
||||
lockKey,
|
||||
(k, existing) -> {
|
||||
if (existing == null || existing.expiryNanos - nowNanos <= 0L) {
|
||||
// No lock, or the previous lease has expired - we take it. Subtraction
|
||||
// form avoids the long-overflow trap that would bite a naive
|
||||
// expiryNanos <= nowNanos comparison around System.nanoTime() rollover.
|
||||
acquired[0] = true;
|
||||
return new LockState(token, expiryNanos);
|
||||
}
|
||||
return existing;
|
||||
});
|
||||
if (!acquired[0]) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(new InProcessHandle(lockKey, token));
|
||||
}
|
||||
|
||||
private void releaseInternal(String lockKey, long token) {
|
||||
locks.compute(
|
||||
lockKey,
|
||||
(k, existing) -> {
|
||||
if (existing == null || existing.ownerToken != token) {
|
||||
// Already removed, expired-and-replaced, or never ours.
|
||||
return existing;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private boolean renewInternal(String lockKey, long token, Duration leaseTime) {
|
||||
long nowNanos = System.nanoTime();
|
||||
boolean[] renewed = {false};
|
||||
locks.compute(
|
||||
lockKey,
|
||||
(k, existing) -> {
|
||||
if (existing == null
|
||||
|| existing.ownerToken != token
|
||||
|| existing.expiryNanos - nowNanos <= 0L) {
|
||||
// Lock is gone or expired; renewal is a no-op so the caller can detect it.
|
||||
return existing;
|
||||
}
|
||||
existing.expiryNanos = nowNanos + leaseTime.toNanos();
|
||||
renewed[0] = true;
|
||||
return existing;
|
||||
});
|
||||
return renewed[0];
|
||||
}
|
||||
|
||||
private final class InProcessHandle implements LockHandle {
|
||||
private final String lockKey;
|
||||
private final long token;
|
||||
private boolean released;
|
||||
|
||||
InProcessHandle(String lockKey, long token) {
|
||||
this.lockKey = lockKey;
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void release() {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
releaseInternal(lockKey, token);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized boolean renew(Duration leaseTime) {
|
||||
if (released) {
|
||||
return false;
|
||||
}
|
||||
return renewInternal(lockKey, token, leaseTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
package stirling.software.common.cluster.inprocess;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import stirling.software.common.cluster.ClusterNode;
|
||||
import stirling.software.common.cluster.InstanceRegistry;
|
||||
|
||||
public class InProcessInstanceRegistry implements InstanceRegistry {
|
||||
|
||||
private final AtomicReference<ClusterNode> self = new AtomicReference<>();
|
||||
|
||||
@Override
|
||||
public void register(ClusterNode node, Duration heartbeatTtl) {
|
||||
self.set(node);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ClusterNode> lookup(String nodeId) {
|
||||
ClusterNode current = self.get();
|
||||
return current != null && current.nodeId().equals(nodeId)
|
||||
? Optional.of(current)
|
||||
: Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<ClusterNode> activeNodes() {
|
||||
ClusterNode current = self.get();
|
||||
return current == null ? Collections.emptyList() : Collections.singletonList(current);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deregister(String nodeId) {
|
||||
ClusterNode current = self.get();
|
||||
if (current != null && current.nodeId().equals(nodeId)) {
|
||||
self.set(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
-96
@@ -1,96 +0,0 @@
|
||||
package stirling.software.common.cluster.inprocess;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.cluster.JobStore;
|
||||
import stirling.software.common.cluster.JobStoreEntry;
|
||||
|
||||
@Slf4j
|
||||
public class InProcessJobStore implements JobStore {
|
||||
|
||||
private final ConcurrentHashMap<String, Holder> entries = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public void put(JobStoreEntry entry, Duration ttl) {
|
||||
Instant expiry = ttl == null ? Instant.MAX : Instant.now().plus(ttl);
|
||||
entries.put(entry.jobId(), new Holder(entry, expiry));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<JobStoreEntry> get(String jobId) {
|
||||
Holder h = entries.get(jobId);
|
||||
if (h == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (h.isExpired()) {
|
||||
entries.remove(jobId, h);
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(h.entry);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String jobId) {
|
||||
entries.remove(jobId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists(String jobId) {
|
||||
return get(jobId).isPresent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> findJobIdByFileId(String fileId) {
|
||||
for (Map.Entry<String, Holder> e : entries.entrySet()) {
|
||||
Holder h = e.getValue();
|
||||
if (h.isExpired()) {
|
||||
continue;
|
||||
}
|
||||
List<String> fileIds = h.entry.fileIds();
|
||||
if (fileIds != null && fileIds.contains(fileId)) {
|
||||
return Optional.of(e.getKey());
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<JobStoreEntry> all() {
|
||||
List<JobStoreEntry> result = new ArrayList<>(entries.size());
|
||||
for (Holder h : entries.values()) {
|
||||
if (!h.isExpired()) {
|
||||
result.add(h.entry);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Drop entries whose TTL has elapsed. Called by the {@code TaskManager} cleanup scheduler. */
|
||||
public int purgeExpired() {
|
||||
int removed = 0;
|
||||
Instant now = Instant.now();
|
||||
for (Map.Entry<String, Holder> e : entries.entrySet()) {
|
||||
if (!e.getValue().expiry.equals(Instant.MAX) && e.getValue().expiry.isBefore(now)) {
|
||||
if (entries.remove(e.getKey(), e.getValue())) {
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
private record Holder(JobStoreEntry entry, Instant expiry) {
|
||||
boolean isExpired() {
|
||||
return !expiry.equals(Instant.MAX) && expiry.isBefore(Instant.now());
|
||||
}
|
||||
}
|
||||
}
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
package stirling.software.common.cluster.inprocess;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import stirling.software.common.cluster.KeyValueCache;
|
||||
|
||||
public class InProcessKeyValueCache implements KeyValueCache {
|
||||
|
||||
private final ConcurrentHashMap<String, ConcurrentHashMap<String, Expiring>> namespaces =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public void put(String namespace, String key, String value, Duration ttl) {
|
||||
Instant expiry = ttl == null ? Instant.MAX : Instant.now().plus(ttl);
|
||||
namespaces
|
||||
.computeIfAbsent(namespace, n -> new ConcurrentHashMap<>())
|
||||
.put(key, new Expiring(value, expiry));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> get(String namespace, String key) {
|
||||
Map<String, Expiring> ns = namespaces.get(namespace);
|
||||
if (ns == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Expiring e = ns.get(key);
|
||||
if (e == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (e.expiry.isBefore(Instant.now())) {
|
||||
ns.remove(key, e);
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(e.value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void evict(String namespace, String key) {
|
||||
Map<String, Expiring> ns = namespaces.get(namespace);
|
||||
if (ns != null) {
|
||||
ns.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void evictNamespace(String namespace) {
|
||||
namespaces.remove(namespace);
|
||||
}
|
||||
|
||||
private record Expiring(String value, Instant expiry) {}
|
||||
}
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
package stirling.software.common.cluster.inprocess;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import io.github.bucket4j.Bandwidth;
|
||||
import io.github.bucket4j.Bucket;
|
||||
import io.github.bucket4j.ConsumptionProbe;
|
||||
import io.github.bucket4j.local.LocalBucketBuilder;
|
||||
|
||||
import stirling.software.common.cluster.RateLimitStore;
|
||||
|
||||
/** Bucket4j-backed token bucket implementation of {@link RateLimitStore}. */
|
||||
public class InProcessRateLimitStore implements RateLimitStore {
|
||||
|
||||
/** Cap to bound memory; oldest accessed buckets are evicted. */
|
||||
private static final int MAX_BUCKETS = 10_000;
|
||||
|
||||
private final Map<String, Bucket> buckets =
|
||||
Collections.synchronizedMap(
|
||||
new LinkedHashMap<String, Bucket>(256, 0.75f, true) {
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<String, Bucket> eldest) {
|
||||
return size() > MAX_BUCKETS;
|
||||
}
|
||||
});
|
||||
|
||||
@Override
|
||||
public RateLimitDecision tryConsume(String bucketKey, long capacity, Duration refillPeriod) {
|
||||
String compositeKey = bucketKey + "|" + capacity + "|" + refillPeriod.toNanos();
|
||||
Bucket bucket =
|
||||
buckets.computeIfAbsent(compositeKey, k -> buildBucket(capacity, refillPeriod));
|
||||
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
|
||||
return new RateLimitDecision(
|
||||
probe.isConsumed(),
|
||||
probe.getRemainingTokens(),
|
||||
probe.isConsumed() ? 0L : probe.getNanosToWaitForRefill());
|
||||
}
|
||||
|
||||
private static Bucket buildBucket(long capacity, Duration refillPeriod) {
|
||||
Bandwidth limit =
|
||||
Bandwidth.builder().capacity(capacity).refillGreedy(capacity, refillPeriod).build();
|
||||
LocalBucketBuilder builder = Bucket.builder();
|
||||
builder.addLimit(limit);
|
||||
return builder.build();
|
||||
}
|
||||
}
|
||||
-128
@@ -1,128 +0,0 @@
|
||||
package stirling.software.common.cluster.inprocess;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.cluster.FileStore;
|
||||
|
||||
/** Local-disk {@link FileStore} storing files under a base directory keyed by a UUID file id. */
|
||||
@Slf4j
|
||||
public class LocalDiskFileStore implements FileStore {
|
||||
|
||||
private final String baseDirPath;
|
||||
|
||||
public LocalDiskFileStore(String baseDirPath) {
|
||||
this.baseDirPath = baseDirPath;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stored store(InputStream in, String originalName) throws IOException {
|
||||
String fileId = UUID.randomUUID().toString();
|
||||
Path filePath = resolve(fileId);
|
||||
Files.createDirectories(filePath.getParent());
|
||||
boolean success = false;
|
||||
try {
|
||||
long size = Files.copy(in, filePath);
|
||||
success = true;
|
||||
return new Stored(fileId, size);
|
||||
} finally {
|
||||
if (!success) {
|
||||
try {
|
||||
Files.deleteIfExists(filePath);
|
||||
} catch (IOException cleanupEx) {
|
||||
log.warn(
|
||||
"Failed to clean up partial file {} after store failure",
|
||||
filePath,
|
||||
cleanupEx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* File-to-file copy. {@link Files#copy(Path, Path, java.nio.file.CopyOption...)} can use {@code
|
||||
* sendfile(2)} on Linux for a zero-copy kernel transfer when source and destination share a
|
||||
* filesystem, avoiding the streaming overhead of pulling the bytes through the JVM heap. Reads
|
||||
* the source size before copying so the post-copy stat is unnecessary.
|
||||
*/
|
||||
@Override
|
||||
public Stored store(Path source, String originalName) throws IOException {
|
||||
String fileId = UUID.randomUUID().toString();
|
||||
Path filePath = resolve(fileId);
|
||||
Files.createDirectories(filePath.getParent());
|
||||
long size = Files.size(source);
|
||||
boolean success = false;
|
||||
try {
|
||||
Files.copy(source, filePath);
|
||||
success = true;
|
||||
return new Stored(fileId, size);
|
||||
} finally {
|
||||
if (!success) {
|
||||
try {
|
||||
Files.deleteIfExists(filePath);
|
||||
} catch (IOException cleanupEx) {
|
||||
log.warn(
|
||||
"Failed to clean up partial file {} after store failure",
|
||||
filePath,
|
||||
cleanupEx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream retrieve(String fileId) throws IOException {
|
||||
return new BufferedInputStream(Files.newInputStream(resolve(fileId)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] retrieveBytes(String fileId) throws IOException {
|
||||
Path filePath = resolve(fileId);
|
||||
if (!Files.exists(filePath)) {
|
||||
throw new IOException("File not found with ID: " + fileId);
|
||||
}
|
||||
return Files.readAllBytes(filePath);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long size(String fileId) throws IOException {
|
||||
Path filePath = resolve(fileId);
|
||||
if (!Files.exists(filePath)) {
|
||||
throw new IOException("File not found with ID: " + fileId);
|
||||
}
|
||||
return Files.size(filePath);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean delete(String fileId) {
|
||||
try {
|
||||
return Files.deleteIfExists(resolve(fileId));
|
||||
} catch (IOException e) {
|
||||
log.error("Error deleting file with ID: {}", fileId, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists(String fileId) {
|
||||
return Files.exists(resolve(fileId));
|
||||
}
|
||||
|
||||
public Path resolve(String fileId) {
|
||||
if (fileId.contains("..") || fileId.contains("/") || fileId.contains("\\")) {
|
||||
throw new IllegalArgumentException("Invalid file ID");
|
||||
}
|
||||
Path basePath = Path.of(baseDirPath).normalize().toAbsolutePath();
|
||||
Path resolvedPath = basePath.resolve(fileId).normalize();
|
||||
if (!resolvedPath.startsWith(basePath)) {
|
||||
throw new IllegalArgumentException("File ID resolves to an invalid path");
|
||||
}
|
||||
return resolvedPath;
|
||||
}
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
package stirling.software.common.cluster.inprocess;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import stirling.software.common.cluster.FileStore;
|
||||
|
||||
/**
|
||||
* Always-on wiring for the per-node local-disk {@link FileStore}. Active when {@code
|
||||
* cluster.artifactStore=local} (the default; {@code matchIfMissing=true}). The S3 artifact-store
|
||||
* supplies its own bean when {@code cluster.artifactStore=s3}.
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(
|
||||
prefix = "cluster",
|
||||
name = "artifactStore",
|
||||
havingValue = "local",
|
||||
matchIfMissing = true)
|
||||
public class LocalDiskFileStoreConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public FileStore fileStore(@Value("${stirling.tempDir:/tmp/stirling-files}") String tempDir) {
|
||||
return new LocalDiskFileStore(tempDir);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
@@ -78,7 +77,6 @@ public class ApplicationProperties {
|
||||
private PdfEditor pdfEditor = new PdfEditor();
|
||||
private AiEngine aiEngine = new AiEngine();
|
||||
private InternalApi internalApi = new InternalApi();
|
||||
private Cluster cluster = new Cluster();
|
||||
|
||||
@Bean
|
||||
public PropertySource<?> dynamicYamlPropertySource(ConfigurableEnvironment environment)
|
||||
@@ -256,106 +254,6 @@ public class ApplicationProperties {
|
||||
private int longRunningTimeoutSeconds = 600;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cluster backplane configuration. All keys live under the top-level {@code cluster.*} prefix
|
||||
* (e.g. env var {@code CLUSTER_ENABLED}). The master switch is {@link #enabled} and defaults to
|
||||
* off; when off the in-process backplane is wired and no other cluster keys are required.
|
||||
*/
|
||||
@Data
|
||||
public static class Cluster {
|
||||
|
||||
/** Master switch. When {@code false} (default) the in-process backplane is wired. */
|
||||
private boolean enabled = false;
|
||||
|
||||
/** Backplane implementation selector. Valid values: {@code inprocess} | {@code valkey}. */
|
||||
private String backplane = "inprocess";
|
||||
|
||||
/**
|
||||
* Transient cluster job-artifact store selector. Valid values: {@code local} | {@code s3}.
|
||||
*
|
||||
* <p>This is distinct from {@code storage.provider}, which selects the backend for
|
||||
* persistent user-uploaded files. The two switches exist because the user-facing storage
|
||||
* feature is optional ({@code storage.enabled=false} is common) but every multi-node
|
||||
* cluster still needs a shared artifact store to serve cross-node downloads. Both
|
||||
* implementations share credentials from {@code storage.s3.*} when set to {@code s3}.
|
||||
*/
|
||||
private String artifactStore = "local";
|
||||
|
||||
private Valkey valkey = new Valkey();
|
||||
private Node node = new Node();
|
||||
|
||||
private transient String cachedNodeId;
|
||||
|
||||
public NodeRole resolvedRole() {
|
||||
if (node == null || node.getRole() == null) {
|
||||
return NodeRole.BOTH;
|
||||
}
|
||||
String value = node.getRole().trim().toUpperCase(Locale.ROOT);
|
||||
try {
|
||||
return NodeRole.valueOf(value);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return NodeRole.BOTH;
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized String resolvedNodeId() {
|
||||
if (node != null && node.getId() != null && !node.getId().isBlank()) {
|
||||
return node.getId();
|
||||
}
|
||||
if (cachedNodeId == null) {
|
||||
cachedNodeId = UUID.randomUUID().toString();
|
||||
}
|
||||
return cachedNodeId;
|
||||
}
|
||||
|
||||
public enum NodeRole {
|
||||
WEB,
|
||||
WORKER,
|
||||
BOTH
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Valkey {
|
||||
/**
|
||||
* {@code redis://host:6379} or {@code rediss://...} for TLS. Required when cluster mode
|
||||
* is on and backplane is valkey.
|
||||
*/
|
||||
private String url = "";
|
||||
|
||||
private Tls tls = new Tls();
|
||||
|
||||
@Data
|
||||
public static class Tls {
|
||||
/**
|
||||
* When {@code true}, skip Valkey/Redis TLS certificate verification (dev/test
|
||||
* only). Leave {@code false} in production.
|
||||
*/
|
||||
private boolean skipCertVerification = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Node {
|
||||
/** Optional explicit node id. Blank = auto-generated UUID at startup. */
|
||||
private String id = "";
|
||||
|
||||
/** {@code web} | {@code worker} | {@code both}. */
|
||||
private String role = "both";
|
||||
|
||||
/**
|
||||
* Internal cluster address advertised in the instance registry (host:port). Blank =
|
||||
* derived at startup.
|
||||
*/
|
||||
private String internalAddress = "";
|
||||
|
||||
/** {@code http} | {@code https} - scheme used when peers call this node. */
|
||||
private String scheme = "http";
|
||||
|
||||
/** Heartbeat publish interval for the instance registry, in milliseconds. */
|
||||
private long heartbeatIntervalMs = 5000;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP timeouts for loopback calls to internal Stirling API endpoints, used by the AI workflow
|
||||
* executor and the pipeline processor. A bounded read timeout prevents a hung tool (e.g. an
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.PipedInputStream;
|
||||
import java.io.PipedOutputStream;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -16,11 +18,9 @@ import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBo
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.cluster.FileStore;
|
||||
|
||||
/**
|
||||
* Service for storing and retrieving files with unique file IDs. Used by the AutoJobPostMapping
|
||||
* system to handle file references. Disk I/O is delegated to the injected {@link FileStore} bean.
|
||||
* system to handle file references.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -30,150 +30,251 @@ public class FileStorage {
|
||||
/** Holds the result of a stream-to-disk store operation: the file ID and the bytes written. */
|
||||
public record StoredFile(String fileId, long size) {}
|
||||
|
||||
@Value("${stirling.tempDir:/tmp/stirling-files}")
|
||||
private String tempDirPath;
|
||||
|
||||
private final FileOrUploadService fileOrUploadService;
|
||||
private final FileStore fileStore;
|
||||
|
||||
/**
|
||||
* Store a file and return its unique ID
|
||||
*
|
||||
* @param file The file to store
|
||||
* @return The unique ID assigned to the file
|
||||
* @throws IOException If there is an error storing the file
|
||||
*/
|
||||
public String storeFile(MultipartFile file) throws IOException {
|
||||
// Fast path: when Spring buffered the multipart to disk (typical for large uploads), the
|
||||
// backing Resource exposes a real File. Hand the Path to the FileStore so it can do a
|
||||
// file-to-file copy (Linux sendfile, no copy through Java heap) rather than streaming
|
||||
// the bytes through an 8K buffer. Falls back to the InputStream path for in-memory
|
||||
// multiparts, exotic Resource impls, and anything that does not back onto a File.
|
||||
Resource res;
|
||||
try {
|
||||
res = file.getResource();
|
||||
} catch (RuntimeException ignored) {
|
||||
res = null;
|
||||
}
|
||||
if (res != null && res.isFile()) {
|
||||
try {
|
||||
FileStore.Stored stored =
|
||||
fileStore.store(res.getFile().toPath(), file.getOriginalFilename());
|
||||
log.debug("Stored file with ID: {} (fast path)", stored.fileId());
|
||||
return stored.fileId();
|
||||
} catch (IOException ex) {
|
||||
// Some Resource impls advertise isFile()=true but throw on getFile(); fall through.
|
||||
log.debug("Resource fast path failed, falling back to stream copy", ex);
|
||||
}
|
||||
}
|
||||
try (InputStream in = file.getInputStream()) {
|
||||
FileStore.Stored stored = fileStore.store(in, file.getOriginalFilename());
|
||||
log.debug("Stored file with ID: {}", stored.fileId());
|
||||
return stored.fileId();
|
||||
}
|
||||
String fileId = generateFileId();
|
||||
Path filePath = getFilePath(fileId);
|
||||
|
||||
// Ensure the directory exists
|
||||
Files.createDirectories(filePath.getParent());
|
||||
|
||||
// Transfer the file to the storage location
|
||||
file.transferTo(filePath.toFile());
|
||||
|
||||
log.debug("Stored file with ID: {}", fileId);
|
||||
return fileId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a byte array as a file and return its unique ID
|
||||
*
|
||||
* @param bytes The byte array to store
|
||||
* @param originalName The original name of the file (for extension)
|
||||
* @return The unique ID assigned to the file
|
||||
* @throws IOException If there is an error storing the file
|
||||
*/
|
||||
public String storeBytes(byte[] bytes, String originalName) throws IOException {
|
||||
FileStore.Stored stored = fileStore.store(new ByteArrayInputStream(bytes), originalName);
|
||||
log.debug("Stored byte array with ID: {}", stored.fileId());
|
||||
return stored.fileId();
|
||||
String fileId = generateFileId();
|
||||
Path filePath = getFilePath(fileId);
|
||||
|
||||
// Ensure the directory exists
|
||||
Files.createDirectories(filePath.getParent());
|
||||
|
||||
// Write the bytes to the file
|
||||
Files.write(filePath, bytes);
|
||||
|
||||
log.debug("Stored byte array with ID: {}", fileId);
|
||||
return fileId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a file by its ID as a MultipartFile
|
||||
*
|
||||
* @param fileId The ID of the file to retrieve
|
||||
* @return The file as a MultipartFile
|
||||
* @throws IOException If the file doesn't exist or can't be read
|
||||
*/
|
||||
public MultipartFile retrieveFile(String fileId) throws IOException {
|
||||
byte[] fileData = fileStore.retrieveBytes(fileId);
|
||||
Path filePath = getFilePath(fileId);
|
||||
|
||||
if (!Files.exists(filePath)) {
|
||||
throw new IOException("File not found with ID: " + fileId);
|
||||
}
|
||||
|
||||
byte[] fileData = Files.readAllBytes(filePath);
|
||||
return fileOrUploadService.toMockMultipartFile(fileId, fileData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a file by its ID as a byte array
|
||||
*
|
||||
* @param fileId The ID of the file to retrieve
|
||||
* @return The file as a byte array
|
||||
* @throws IOException If the file doesn't exist or can't be read
|
||||
*/
|
||||
public byte[] retrieveBytes(String fileId) throws IOException {
|
||||
return fileStore.retrieveBytes(fileId);
|
||||
Path filePath = getFilePath(fileId);
|
||||
|
||||
if (!Files.exists(filePath)) {
|
||||
throw new IOException("File not found with ID: " + fileId);
|
||||
}
|
||||
|
||||
return Files.readAllBytes(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a file by its ID as a streaming InputStream. The caller is responsible for closing
|
||||
* the returned stream.
|
||||
*
|
||||
* @param fileId The ID of the file to retrieve
|
||||
* @return A buffered InputStream for the file
|
||||
* @throws IOException If the file doesn't exist or can't be read
|
||||
*/
|
||||
public InputStream retrieveInputStream(String fileId) throws IOException {
|
||||
return fileStore.retrieve(fileId);
|
||||
Path filePath = getFilePath(fileId);
|
||||
// Let Files.newInputStream throw NoSuchFileException naturally — avoids TOCTOU race
|
||||
// between exists-check and open when another thread may delete concurrently.
|
||||
return new BufferedInputStream(Files.newInputStream(filePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store data from an InputStream as a file and return its unique ID and byte count. Streams
|
||||
* directly to disk without buffering the entire content in heap.
|
||||
*
|
||||
* @param inputStream The input stream to read from
|
||||
* @param originalName The original name of the file (unused, kept for API symmetry)
|
||||
* @return A {@link StoredFile} containing the file ID and the number of bytes written
|
||||
* @throws IOException If there is an error storing the file
|
||||
*/
|
||||
public StoredFile storeInputStream(InputStream inputStream, String originalName)
|
||||
throws IOException {
|
||||
FileStore.Stored stored = fileStore.store(inputStream, originalName);
|
||||
log.debug("Stored input stream with ID: {}", stored.fileId());
|
||||
return new StoredFile(stored.fileId(), stored.size());
|
||||
String fileId = generateFileId();
|
||||
Path filePath = getFilePath(fileId);
|
||||
Files.createDirectories(filePath.getParent());
|
||||
long size = Files.copy(inputStream, filePath);
|
||||
log.debug("Stored input stream with ID: {}", fileId);
|
||||
return new StoredFile(fileId, size);
|
||||
}
|
||||
|
||||
public String storeFromStreamingBody(StreamingResponseBody body, String originalName)
|
||||
throws IOException {
|
||||
// Hold Throwable not IOException: an unchecked failure (NPE, IllegalState, OOM, etc.)
|
||||
// from the body writer would otherwise close the pipe with EOF and the consumer would
|
||||
// return a truncated file with no error surfaced to the caller.
|
||||
AtomicReference<Throwable> bodyError = new AtomicReference<>();
|
||||
try (PipedOutputStream out = new PipedOutputStream();
|
||||
PipedInputStream in = new PipedInputStream(out, 8192)) {
|
||||
var executor = Executors.newSingleThreadExecutor(Thread.ofVirtual().factory());
|
||||
java.util.concurrent.Future<?> task = null;
|
||||
try {
|
||||
task =
|
||||
executor.submit(
|
||||
() -> {
|
||||
try {
|
||||
body.writeTo(out);
|
||||
} catch (Throwable ex) {
|
||||
bodyError.set(ex);
|
||||
} finally {
|
||||
try {
|
||||
out.close();
|
||||
} catch (IOException ignored) {
|
||||
// closed on the consumer side too
|
||||
}
|
||||
}
|
||||
});
|
||||
FileStore.Stored stored = fileStore.store(in, originalName);
|
||||
Throwable writerErr = bodyError.get();
|
||||
if (writerErr != null) {
|
||||
// Body failed mid-write: the FileStore persisted a truncated entry.
|
||||
// Best-effort delete so we don't leak partial files; never let cleanup
|
||||
// mask the original writer error.
|
||||
try {
|
||||
fileStore.delete(stored.fileId());
|
||||
} catch (RuntimeException cleanupEx) {
|
||||
log.warn(
|
||||
"Failed to delete partial file {} after writer error: {}",
|
||||
stored.fileId(),
|
||||
cleanupEx.getMessage());
|
||||
}
|
||||
if (writerErr instanceof IOException ioe) {
|
||||
throw ioe;
|
||||
}
|
||||
throw new IOException(
|
||||
"StreamingResponseBody writer failed: " + writerErr.getMessage(),
|
||||
writerErr);
|
||||
}
|
||||
log.debug("Stored StreamingResponseBody with ID: {}", stored.fileId());
|
||||
return stored.fileId();
|
||||
} finally {
|
||||
// Interrupt and join the writer task: shutdown() alone returns immediately and a
|
||||
// failed store leaves the writer running, leaking a thread per failed upload.
|
||||
if (task != null && !task.isDone()) {
|
||||
task.cancel(true);
|
||||
}
|
||||
executor.shutdown();
|
||||
String fileId = generateFileId();
|
||||
Path filePath = getFilePath(fileId);
|
||||
Files.createDirectories(filePath.getParent());
|
||||
boolean success = false;
|
||||
try (OutputStream os = new BufferedOutputStream(Files.newOutputStream(filePath))) {
|
||||
body.writeTo(os);
|
||||
success = true;
|
||||
} finally {
|
||||
if (!success) {
|
||||
try {
|
||||
if (!executor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)) {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
} catch (InterruptedException ie) {
|
||||
executor.shutdownNow();
|
||||
Thread.currentThread().interrupt();
|
||||
Files.deleteIfExists(filePath);
|
||||
} catch (IOException cleanupEx) {
|
||||
log.warn(
|
||||
"Failed to clean up partial file {} after store failure",
|
||||
filePath,
|
||||
cleanupEx);
|
||||
}
|
||||
}
|
||||
}
|
||||
log.debug("Stored StreamingResponseBody with ID: {}", fileId);
|
||||
return fileId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a {@link Resource} body to disk, returning the generated file ID. Used by the async
|
||||
* job pipeline to capture {@code ResponseEntity<Resource>} results produced by controllers.
|
||||
*/
|
||||
public String storeFromResource(Resource resource, String originalName) throws IOException {
|
||||
String fileId = generateFileId();
|
||||
Path filePath = getFilePath(fileId);
|
||||
Files.createDirectories(filePath.getParent());
|
||||
boolean success = false;
|
||||
try (InputStream in = resource.getInputStream()) {
|
||||
FileStore.Stored stored = fileStore.store(in, originalName);
|
||||
log.debug("Stored Resource with ID: {}", stored.fileId());
|
||||
return stored.fileId();
|
||||
Files.copy(in, filePath);
|
||||
success = true;
|
||||
} finally {
|
||||
if (!success) {
|
||||
try {
|
||||
Files.deleteIfExists(filePath);
|
||||
} catch (IOException cleanupEx) {
|
||||
log.warn(
|
||||
"Failed to clean up partial file {} after store failure",
|
||||
filePath,
|
||||
cleanupEx);
|
||||
}
|
||||
}
|
||||
}
|
||||
log.debug("Stored Resource with ID: {}", fileId);
|
||||
return fileId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file by its ID
|
||||
*
|
||||
* @param fileId The ID of the file to delete
|
||||
* @return true if the file was deleted, false otherwise
|
||||
*/
|
||||
public boolean deleteFile(String fileId) {
|
||||
try {
|
||||
Path filePath = getFilePath(fileId);
|
||||
return Files.deleteIfExists(filePath);
|
||||
} catch (IOException e) {
|
||||
log.error("Error deleting file with ID: {}", fileId, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean deleteFile(String fileId) {
|
||||
return fileStore.delete(fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file exists by its ID
|
||||
*
|
||||
* @param fileId The ID of the file to check
|
||||
* @return true if the file exists, false otherwise
|
||||
*/
|
||||
public boolean fileExists(String fileId) {
|
||||
return fileStore.exists(fileId);
|
||||
Path filePath = getFilePath(fileId);
|
||||
return Files.exists(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the size of a file by its ID without loading the content into memory
|
||||
*
|
||||
* @param fileId The ID of the file
|
||||
* @return The size of the file in bytes
|
||||
* @throws IOException If the file doesn't exist or can't be read
|
||||
*/
|
||||
public long getFileSize(String fileId) throws IOException {
|
||||
return fileStore.size(fileId);
|
||||
Path filePath = getFilePath(fileId);
|
||||
|
||||
if (!Files.exists(filePath)) {
|
||||
throw new IOException("File not found with ID: " + fileId);
|
||||
}
|
||||
|
||||
return Files.size(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path for a file ID
|
||||
*
|
||||
* @param fileId The ID of the file
|
||||
* @return The path to the file
|
||||
* @throws IllegalArgumentException if fileId contains path traversal characters or resolves
|
||||
* outside base directory
|
||||
*/
|
||||
private Path getFilePath(String fileId) {
|
||||
// Validate fileId to prevent path traversal
|
||||
if (fileId.contains("..") || fileId.contains("/") || fileId.contains("\\")) {
|
||||
throw new IllegalArgumentException("Invalid file ID");
|
||||
}
|
||||
|
||||
Path basePath = Path.of(tempDirPath).normalize().toAbsolutePath();
|
||||
Path resolvedPath = basePath.resolve(fileId).normalize();
|
||||
|
||||
// Ensure resolved path is within the base directory
|
||||
if (!resolvedPath.startsWith(basePath)) {
|
||||
throw new IllegalArgumentException("File ID resolves to an invalid path");
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique file ID
|
||||
*
|
||||
* @return A unique file ID
|
||||
*/
|
||||
private String generateFileId() {
|
||||
return UUID.randomUUID().toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,9 @@ package stirling.software.common.service;
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
@@ -20,7 +16,6 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -31,10 +26,6 @@ import jakarta.annotation.PreDestroy;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.cluster.ClusterBackplane;
|
||||
import stirling.software.common.cluster.JobStore;
|
||||
import stirling.software.common.cluster.JobStoreEntry;
|
||||
import stirling.software.common.cluster.JobStoreEntry.JobState;
|
||||
import stirling.software.common.model.job.JobResult;
|
||||
import stirling.software.common.model.job.JobStats;
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
@@ -49,20 +40,20 @@ public class TaskManager {
|
||||
private int jobResultExpiryMinutes = 30;
|
||||
|
||||
private final FileStorage fileStorage;
|
||||
private final JobStore jobStore;
|
||||
private final ClusterBackplane clusterBackplane;
|
||||
private final ScheduledExecutorService cleanupExecutor =
|
||||
Executors.newSingleThreadScheduledExecutor(
|
||||
Thread.ofVirtual().name("task-cleanup-", 0).factory());
|
||||
|
||||
@Autowired
|
||||
public TaskManager(
|
||||
FileStorage fileStorage, JobStore jobStore, ClusterBackplane clusterBackplane) {
|
||||
/** Initialize the task manager and start the cleanup scheduler */
|
||||
public TaskManager(FileStorage fileStorage) {
|
||||
this.fileStorage = fileStorage;
|
||||
this.jobStore = jobStore;
|
||||
this.clusterBackplane = clusterBackplane;
|
||||
|
||||
cleanupExecutor.scheduleAtFixedRate(this::cleanupOldJobs, 10, 10, TimeUnit.MINUTES);
|
||||
// Schedule periodic cleanup of old job results
|
||||
cleanupExecutor.scheduleAtFixedRate(
|
||||
this::cleanupOldJobs,
|
||||
10, // Initial delay
|
||||
10, // Interval
|
||||
TimeUnit.MINUTES);
|
||||
|
||||
log.debug(
|
||||
"Task manager initialized with job result expiry of {} minutes",
|
||||
@@ -75,9 +66,7 @@ public class TaskManager {
|
||||
* @param jobId The job ID
|
||||
*/
|
||||
public void createTask(String jobId) {
|
||||
JobResult result = JobResult.createNew(jobId);
|
||||
jobResults.put(jobId, result);
|
||||
writeThrough(jobId, result);
|
||||
jobResults.put(jobId, JobResult.createNew(jobId));
|
||||
log.debug("Created task with job ID: {}", jobId);
|
||||
}
|
||||
|
||||
@@ -90,7 +79,6 @@ public class TaskManager {
|
||||
public void setResult(String jobId, Object result) {
|
||||
JobResult jobResult = getOrCreateJobResult(jobId);
|
||||
jobResult.completeWithResult(result);
|
||||
writeThrough(jobId, jobResult);
|
||||
log.debug("Set result for job ID: {}", jobId);
|
||||
}
|
||||
|
||||
@@ -113,7 +101,6 @@ public class TaskManager {
|
||||
extractZipToIndividualFiles(fileId, originalFileName);
|
||||
if (!extractedFiles.isEmpty()) {
|
||||
jobResult.completeWithFiles(extractedFiles);
|
||||
writeThrough(jobId, jobResult);
|
||||
log.debug(
|
||||
"Set multiple file results for job ID: {} with {} files extracted from"
|
||||
+ " ZIP",
|
||||
@@ -140,7 +127,6 @@ public class TaskManager {
|
||||
"Failed to get file size for job {}: {}. Using size 0.", jobId, e.getMessage());
|
||||
jobResult.completeWithSingleFile(fileId, originalFileName, contentType, 0);
|
||||
}
|
||||
writeThrough(jobId, jobResult);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,7 +138,6 @@ public class TaskManager {
|
||||
public void setMultipleFileResults(String jobId, List<ResultFile> resultFiles) {
|
||||
JobResult jobResult = getOrCreateJobResult(jobId);
|
||||
jobResult.completeWithFiles(resultFiles);
|
||||
writeThrough(jobId, jobResult);
|
||||
log.debug(
|
||||
"Set multiple file results for job ID: {} with {} files",
|
||||
jobId,
|
||||
@@ -168,7 +153,6 @@ public class TaskManager {
|
||||
public void setError(String jobId, String error) {
|
||||
JobResult jobResult = getOrCreateJobResult(jobId);
|
||||
jobResult.failWithError(error);
|
||||
writeThrough(jobId, jobResult);
|
||||
log.debug("Set error for job ID: {}: {}", jobId, error);
|
||||
}
|
||||
|
||||
@@ -185,7 +169,6 @@ public class TaskManager {
|
||||
// If no result or error has been set, mark it as complete with an empty result
|
||||
jobResult.completeWithResult("Task completed successfully");
|
||||
}
|
||||
writeThrough(jobId, jobResult);
|
||||
log.debug("Marked job ID: {} as complete", jobId);
|
||||
}
|
||||
|
||||
@@ -222,7 +205,6 @@ public class TaskManager {
|
||||
JobResult jobResult = jobResults.get(jobId);
|
||||
if (jobResult != null) {
|
||||
jobResult.addNote(note);
|
||||
writeThrough(jobId, jobResult);
|
||||
log.debug("Added note to job ID: {}: {}", jobId, note);
|
||||
return true;
|
||||
}
|
||||
@@ -313,11 +295,8 @@ public class TaskManager {
|
||||
return jobResults.computeIfAbsent(jobId, JobResult::createNew);
|
||||
}
|
||||
|
||||
/** Clean up old completed job results. No-op in cluster mode; the backplane TTL owns expiry. */
|
||||
/** Clean up old completed job results */
|
||||
public void cleanupOldJobs() {
|
||||
if (clusterBackplane != null && !clusterBackplane.shouldRunLocalCleanup()) {
|
||||
return;
|
||||
}
|
||||
LocalDateTime expiryThreshold =
|
||||
LocalDateTime.now().minus(jobResultExpiryMinutes, ChronoUnit.MINUTES);
|
||||
int removedCount = 0;
|
||||
@@ -336,9 +315,6 @@ public class TaskManager {
|
||||
|
||||
// Remove the job result
|
||||
jobResults.remove(entry.getKey());
|
||||
if (jobStore != null) {
|
||||
jobStore.delete(entry.getKey());
|
||||
}
|
||||
removedCount++;
|
||||
}
|
||||
}
|
||||
@@ -351,53 +327,6 @@ public class TaskManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirror the in-memory {@code JobResult} into the cluster-visible {@link JobStore}. */
|
||||
private void writeThrough(String jobId, JobResult result) {
|
||||
if (jobStore == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
jobStore.put(toEntry(jobId, result), Duration.ofMinutes(jobResultExpiryMinutes));
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("JobStore write-through failed for job {}: {}", jobId, ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private JobStoreEntry toEntry(String jobId, JobResult result) {
|
||||
JobState state;
|
||||
if (result.isComplete()) {
|
||||
state = result.getError() != null ? JobState.FAILED : JobState.COMPLETE;
|
||||
} else {
|
||||
state = JobState.PENDING;
|
||||
}
|
||||
Instant createdAt = toInstant(result.getCreatedAt());
|
||||
Instant completedAt = toInstant(result.getCompletedAt());
|
||||
List<String> fileIds = new ArrayList<>();
|
||||
if (result.hasFiles()) {
|
||||
for (ResultFile rf : result.getAllResultFiles()) {
|
||||
fileIds.add(rf.getFileId());
|
||||
}
|
||||
}
|
||||
Map<String, String> meta = new HashMap<>();
|
||||
if (result.getNotes() != null && !result.getNotes().isEmpty()) {
|
||||
meta.put("notesCount", Integer.toString(result.getNotes().size()));
|
||||
}
|
||||
String owningNodeId = clusterBackplane == null ? "local" : clusterBackplane.localNodeId();
|
||||
return new JobStoreEntry(
|
||||
jobId,
|
||||
state,
|
||||
owningNodeId,
|
||||
createdAt,
|
||||
completedAt,
|
||||
result.getError(),
|
||||
fileIds,
|
||||
meta);
|
||||
}
|
||||
|
||||
private Instant toInstant(LocalDateTime ldt) {
|
||||
return ldt == null ? null : ldt.atZone(ZoneId.systemDefault()).toInstant();
|
||||
}
|
||||
|
||||
/** Shutdown the cleanup executor */
|
||||
@PreDestroy
|
||||
public void shutdown() {
|
||||
@@ -441,7 +370,7 @@ public class TaskManager {
|
||||
while ((entry = zipIn.getNextEntry()) != null) {
|
||||
if (!entry.isDirectory()) {
|
||||
String contentType = determineContentType(entry.getName());
|
||||
// storeInputStream returns the fileId and byte count - no extra stat needed
|
||||
// storeInputStream returns the fileId and byte count — no extra stat needed
|
||||
FileStorage.StoredFile stored =
|
||||
fileStorage.storeInputStream(zipIn, entry.getName());
|
||||
|
||||
@@ -529,8 +458,7 @@ public class TaskManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the job key that owns a given file ID. Checks the local in-memory map first, then falls
|
||||
* back to the cluster-visible {@link JobStore}.
|
||||
* Find the job key that owns a given file ID.
|
||||
*
|
||||
* @param fileId file identifier to look up
|
||||
* @return scoped job key if found, otherwise null
|
||||
@@ -546,18 +474,6 @@ public class TaskManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (jobStore != null) {
|
||||
// Propagate JobStore failures: returning null on a backplane outage would conflate
|
||||
// "no such file" with "lookup unavailable" and the caller would respond 404 to a
|
||||
// transient blip that should be retried. Let Spring's exception handler surface a
|
||||
// 5xx so clients know to retry.
|
||||
try {
|
||||
return jobStore.findJobIdByFileId(fileId).orElse(null);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("JobStore findJobIdByFileId failed for {}: {}", fileId, e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,17 +56,4 @@ class ArchitectureTest {
|
||||
.resideInAPackage("stirling.software.saas..");
|
||||
rule.check(commonClasses);
|
||||
}
|
||||
|
||||
@Test
|
||||
void clusterInterfacesHaveNoImplementationDependencies() {
|
||||
ArchRule rule =
|
||||
noClasses()
|
||||
.that()
|
||||
.resideInAPackage("stirling.software.common.cluster..")
|
||||
.should()
|
||||
.dependOnClassesThat()
|
||||
.resideInAnyPackage(
|
||||
"stirling.software.proprietary..", "stirling.software.saas..");
|
||||
rule.check(commonClasses);
|
||||
}
|
||||
}
|
||||
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
package stirling.software.common.cluster;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class BackplaneContractCompilationTest {
|
||||
|
||||
@Test
|
||||
void jobStoreEntryRecordRoundTrips() {
|
||||
Instant now = Instant.now();
|
||||
JobStoreEntry entry =
|
||||
new JobStoreEntry(
|
||||
"job-1",
|
||||
JobStoreEntry.JobState.PENDING,
|
||||
"node-a",
|
||||
now,
|
||||
null,
|
||||
null,
|
||||
List.of("file-1"),
|
||||
Map.of("k", "v"));
|
||||
assertEquals("job-1", entry.jobId());
|
||||
assertEquals(JobStoreEntry.JobState.PENDING, entry.state());
|
||||
assertEquals("node-a", entry.owningNodeId());
|
||||
assertEquals(now, entry.createdAt());
|
||||
assertEquals(List.of("file-1"), entry.fileIds());
|
||||
assertEquals("v", entry.resultMeta().get("k"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void clusterNodeRecordRoundTrips() {
|
||||
Instant heartbeat = Instant.now();
|
||||
ClusterNode node = new ClusterNode("node-a", "10.0.0.1:8080", heartbeat, "BOTH");
|
||||
assertEquals("node-a", node.nodeId());
|
||||
assertEquals("10.0.0.1:8080", node.internalAddress());
|
||||
assertEquals(heartbeat, node.lastHeartbeat());
|
||||
assertEquals("BOTH", node.role());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rateLimitDecisionRecordRoundTrips() {
|
||||
RateLimitStore.RateLimitDecision d = new RateLimitStore.RateLimitDecision(true, 7, 0L);
|
||||
assertEquals(true, d.allowed());
|
||||
assertEquals(7, d.remainingTokens());
|
||||
assertEquals(0L, d.nanosToWaitForRefill());
|
||||
}
|
||||
}
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
package stirling.software.common.cluster;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.Cluster;
|
||||
|
||||
class ClusterConfigValidationTest {
|
||||
|
||||
@Test
|
||||
void validationPassesWhenDisabled() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
ClusterConfig config = new ClusterConfig(props);
|
||||
assertDoesNotThrow(() -> invokeValidate(config));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validationFailsWhenValkeyEnabledWithoutUrl() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
Cluster cluster = props.getCluster();
|
||||
cluster.setEnabled(true);
|
||||
cluster.setBackplane("valkey");
|
||||
ClusterConfig config = new ClusterConfig(props);
|
||||
assertThrows(IllegalStateException.class, () -> invokeValidate(config));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validationPassesWhenValkeyEnabledWithUrl() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
Cluster cluster = props.getCluster();
|
||||
cluster.setEnabled(true);
|
||||
cluster.setBackplane("valkey");
|
||||
cluster.getValkey().setUrl("redis://localhost:6379");
|
||||
ClusterConfig config = new ClusterConfig(props);
|
||||
assertDoesNotThrow(() -> invokeValidate(config));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validationPassesWhenInProcessEnabled() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
Cluster cluster = props.getCluster();
|
||||
cluster.setEnabled(true);
|
||||
cluster.setBackplane("inprocess");
|
||||
ClusterConfig config = new ClusterConfig(props);
|
||||
assertDoesNotThrow(() -> invokeValidate(config));
|
||||
}
|
||||
|
||||
private void invokeValidate(ClusterConfig config) throws Exception {
|
||||
Method m = ClusterConfig.class.getDeclaredMethod("validate");
|
||||
m.setAccessible(true);
|
||||
try {
|
||||
m.invoke(config);
|
||||
} catch (java.lang.reflect.InvocationTargetException ex) {
|
||||
if (ex.getCause() instanceof RuntimeException re) {
|
||||
throw re;
|
||||
}
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package stirling.software.common.cluster;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.Cluster;
|
||||
|
||||
class ClusterPropertiesTest {
|
||||
|
||||
@Test
|
||||
void defaultsAreDisabledAndInprocess() {
|
||||
Cluster props = new ApplicationProperties().getCluster();
|
||||
assertFalse(props.isEnabled());
|
||||
assertEquals("inprocess", props.getBackplane());
|
||||
assertEquals("local", props.getArtifactStore());
|
||||
assertEquals(Cluster.NodeRole.BOTH, props.resolvedRole());
|
||||
assertEquals("", props.getValkey().getUrl());
|
||||
assertFalse(props.getValkey().getTls().isSkipCertVerification());
|
||||
assertEquals("both", props.getNode().getRole());
|
||||
assertEquals("http", props.getNode().getScheme());
|
||||
assertEquals(5000L, props.getNode().getHeartbeatIntervalMs());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvedRoleParsesCaseInsensitively() {
|
||||
Cluster props = new ApplicationProperties().getCluster();
|
||||
props.getNode().setRole("WEB");
|
||||
assertEquals(Cluster.NodeRole.WEB, props.resolvedRole());
|
||||
|
||||
props.getNode().setRole("web");
|
||||
assertEquals(Cluster.NodeRole.WEB, props.resolvedRole());
|
||||
|
||||
props.getNode().setRole("Worker");
|
||||
assertEquals(Cluster.NodeRole.WORKER, props.resolvedRole());
|
||||
|
||||
props.getNode().setRole("garbage");
|
||||
assertEquals(Cluster.NodeRole.BOTH, props.resolvedRole());
|
||||
|
||||
props.getNode().setRole(null);
|
||||
assertEquals(Cluster.NodeRole.BOTH, props.resolvedRole());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvedNodeIdIsStableAcrossCalls() {
|
||||
Cluster props = new ApplicationProperties().getCluster();
|
||||
String first = props.resolvedNodeId();
|
||||
String second = props.resolvedNodeId();
|
||||
assertNotNull(first);
|
||||
assertEquals(first, second);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvedNodeIdHonoursExplicitId() {
|
||||
Cluster props = new ApplicationProperties().getCluster();
|
||||
props.getNode().setId("abc");
|
||||
assertEquals("abc", props.resolvedNodeId());
|
||||
}
|
||||
}
|
||||
-90
@@ -1,90 +0,0 @@
|
||||
package stirling.software.common.cluster;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import stirling.software.common.cluster.inprocess.InProcessClusterConfiguration;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Verifies the {@link InProcessClusterConfiguration} conditional wiring: in-process beans wire when
|
||||
* cluster mode is off or {@code backplane=inprocess}, and are skipped when {@code
|
||||
* backplane=valkey}.
|
||||
*/
|
||||
class InProcessConfigurationConditionalTest {
|
||||
|
||||
private final ApplicationContextRunner runner =
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(
|
||||
org.springframework.boot.autoconfigure.AutoConfigurations.of(
|
||||
PropertyPlaceholderAutoConfiguration.class))
|
||||
.withUserConfiguration(
|
||||
TestAppPropertiesConfig.class,
|
||||
ClusterConfig.class,
|
||||
InProcessClusterConfiguration.class);
|
||||
|
||||
@Test
|
||||
void inProcessBeansWireWhenClusterDisabled() {
|
||||
runner.run(
|
||||
context ->
|
||||
assertThat(context)
|
||||
.hasNotFailed()
|
||||
.hasSingleBean(ClusterBackplane.class)
|
||||
.hasSingleBean(JobStore.class)
|
||||
.hasSingleBean(RateLimitStore.class)
|
||||
.hasSingleBean(DistributedLock.class)
|
||||
.hasSingleBean(KeyValueCache.class)
|
||||
.hasSingleBean(InstanceRegistry.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void inProcessBeansWireWhenEnabledWithInProcessBackplane() {
|
||||
runner.withPropertyValues("cluster.enabled=true", "cluster.backplane=inprocess")
|
||||
.run(
|
||||
context ->
|
||||
assertThat(context)
|
||||
.hasNotFailed()
|
||||
.hasSingleBean(ClusterBackplane.class)
|
||||
.hasSingleBean(JobStore.class)
|
||||
.hasSingleBean(RateLimitStore.class)
|
||||
.hasSingleBean(DistributedLock.class)
|
||||
.hasSingleBean(KeyValueCache.class)
|
||||
.hasSingleBean(InstanceRegistry.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void inProcessBeansSkippedWhenEnabledWithDistributedBackplane() {
|
||||
runner.withPropertyValues(
|
||||
"cluster.enabled=true",
|
||||
"cluster.backplane=valkey",
|
||||
"cluster.valkey.url=redis://localhost:6379")
|
||||
.run(
|
||||
context ->
|
||||
assertThat(context)
|
||||
.hasNotFailed()
|
||||
.doesNotHaveBean(ClusterBackplane.class)
|
||||
.doesNotHaveBean(JobStore.class)
|
||||
.doesNotHaveBean(RateLimitStore.class)
|
||||
.doesNotHaveBean(DistributedLock.class)
|
||||
.doesNotHaveBean(KeyValueCache.class)
|
||||
.doesNotHaveBean(InstanceRegistry.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand-rolled {@link ApplicationProperties} bean: the production class loads YAML at startup
|
||||
* via a {@code @PostConstruct} hook that isn't appropriate for the slice runner, so we wire a
|
||||
* defaults-only instance here.
|
||||
*/
|
||||
@Configuration
|
||||
static class TestAppPropertiesConfig {
|
||||
@Bean
|
||||
ApplicationProperties applicationProperties() {
|
||||
return new ApplicationProperties();
|
||||
}
|
||||
}
|
||||
}
|
||||
-167
@@ -1,167 +0,0 @@
|
||||
package stirling.software.common.cluster.inprocess;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.cluster.DistributedLock;
|
||||
|
||||
class InProcessDistributedLockTest {
|
||||
|
||||
@Test
|
||||
void acquireReleaseAcquire() {
|
||||
DistributedLock lock = new InProcessDistributedLock();
|
||||
DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofSeconds(30)).orElseThrow();
|
||||
h1.release();
|
||||
assertTrue(lock.tryAcquire("k", Duration.ofSeconds(30)).isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reentryFromSameThreadFails() {
|
||||
DistributedLock lock = new InProcessDistributedLock();
|
||||
DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofSeconds(30)).orElseThrow();
|
||||
Optional<DistributedLock.LockHandle> reentry = lock.tryAcquire("k", Duration.ofSeconds(30));
|
||||
assertFalse(reentry.isPresent(), "in-process lock must be non-reentrant");
|
||||
h1.release();
|
||||
// After release, anyone can acquire again.
|
||||
assertTrue(lock.tryAcquire("k", Duration.ofSeconds(30)).isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondAcquireFromAnotherThreadFails() throws InterruptedException {
|
||||
DistributedLock lock = new InProcessDistributedLock();
|
||||
DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofSeconds(30)).orElseThrow();
|
||||
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
AtomicBoolean acquired = new AtomicBoolean(true);
|
||||
Thread t =
|
||||
new Thread(
|
||||
() -> {
|
||||
Optional<DistributedLock.LockHandle> attempt =
|
||||
lock.tryAcquire("k", Duration.ofSeconds(30));
|
||||
acquired.set(attempt.isPresent());
|
||||
attempt.ifPresent(DistributedLock.LockHandle::release);
|
||||
done.countDown();
|
||||
});
|
||||
t.start();
|
||||
assertTrue(done.await(2, TimeUnit.SECONDS));
|
||||
assertFalse(acquired.get());
|
||||
h1.release();
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaseExpiryAllowsTakeoverEvenWithoutRelease() throws InterruptedException {
|
||||
// Acquire with a short lease, never call release, then try to acquire again after the
|
||||
// lease has elapsed. Matches Redis SET-NX-EX semantics - the second caller gets the lock
|
||||
// because the first lease auto-expired. 250ms lease + 350ms wait gives CI generous slack.
|
||||
DistributedLock lock = new InProcessDistributedLock();
|
||||
DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofMillis(250)).orElseThrow();
|
||||
Thread.sleep(350);
|
||||
Optional<DistributedLock.LockHandle> takeover =
|
||||
lock.tryAcquire("k", Duration.ofSeconds(30));
|
||||
assertTrue(
|
||||
takeover.isPresent(),
|
||||
"expired lease must release the lock so a new caller can take over");
|
||||
// Calling release() on the original handle after takeover must be a no-op (token check).
|
||||
h1.release();
|
||||
// The takeover holder is still the legitimate owner.
|
||||
assertFalse(lock.tryAcquire("k", Duration.ofSeconds(30)).isPresent());
|
||||
takeover.get().release();
|
||||
}
|
||||
|
||||
@Test
|
||||
void renewExtendsLease() throws InterruptedException {
|
||||
// Acquire with a short lease, renew it before it expires, then verify the lock is still
|
||||
// held past the original expiry point. 200ms initial + renew to 2s + wait 350ms.
|
||||
DistributedLock lock = new InProcessDistributedLock();
|
||||
DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofMillis(200)).orElseThrow();
|
||||
assertTrue(h1.renew(Duration.ofSeconds(2)), "renew on a held lease must succeed");
|
||||
Thread.sleep(350);
|
||||
assertFalse(
|
||||
lock.tryAcquire("k", Duration.ofSeconds(30)).isPresent(),
|
||||
"renew should have pushed expiry well past the original 200ms");
|
||||
h1.release();
|
||||
}
|
||||
|
||||
@Test
|
||||
void renewAfterReleaseFails() {
|
||||
DistributedLock lock = new InProcessDistributedLock();
|
||||
DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofSeconds(30)).orElseThrow();
|
||||
h1.release();
|
||||
assertFalse(h1.renew(Duration.ofSeconds(30)), "renew on a released handle must fail");
|
||||
}
|
||||
|
||||
/**
|
||||
* Concurrency stress: many threads contending on the same key with each holder respecting the
|
||||
* lease (hold << lease). The lock behaves as a strict mutex in this regime so asserting
|
||||
* mutual exclusion is meaningful. A separate test ({@link
|
||||
* #leaseExpiryAllowsTakeoverEvenWithoutRelease}) covers the takeover-across-expiry branch,
|
||||
* which legitimately allows two holders momentarily and is split-brain behaviour inherent to
|
||||
* any lease-based lock.
|
||||
*/
|
||||
@Test
|
||||
void concurrentContentionPreservesMutualExclusion() throws InterruptedException {
|
||||
DistributedLock lock = new InProcessDistributedLock();
|
||||
int threads = 16;
|
||||
int attemptsPerThread = 200;
|
||||
// Lease far exceeds any plausible hold time, so the takeover branch never triggers in
|
||||
// this test and the lock acts as a strict mutex.
|
||||
Duration lease = Duration.ofSeconds(5);
|
||||
java.util.concurrent.atomic.AtomicInteger concurrentHolders =
|
||||
new java.util.concurrent.atomic.AtomicInteger();
|
||||
java.util.concurrent.atomic.AtomicInteger maxConcurrent =
|
||||
new java.util.concurrent.atomic.AtomicInteger();
|
||||
java.util.concurrent.atomic.AtomicInteger acquires =
|
||||
new java.util.concurrent.atomic.AtomicInteger();
|
||||
java.util.concurrent.atomic.AtomicReference<Throwable> firstFailure =
|
||||
new java.util.concurrent.atomic.AtomicReference<>();
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
CountDownLatch done = new CountDownLatch(threads);
|
||||
|
||||
for (int i = 0; i < threads; i++) {
|
||||
new Thread(
|
||||
() -> {
|
||||
try {
|
||||
start.await();
|
||||
for (int j = 0; j < attemptsPerThread; j++) {
|
||||
Optional<DistributedLock.LockHandle> h =
|
||||
lock.tryAcquire("hot", lease);
|
||||
if (h.isPresent()) {
|
||||
int now = concurrentHolders.incrementAndGet();
|
||||
maxConcurrent.accumulateAndGet(now, Math::max);
|
||||
acquires.incrementAndGet();
|
||||
// Trivial critical section; well within lease.
|
||||
concurrentHolders.decrementAndGet();
|
||||
h.get().release();
|
||||
}
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
firstFailure.compareAndSet(null, t);
|
||||
} finally {
|
||||
done.countDown();
|
||||
}
|
||||
},
|
||||
"lock-stress-" + i)
|
||||
.start();
|
||||
}
|
||||
start.countDown();
|
||||
assertTrue(done.await(30, TimeUnit.SECONDS), "stress workers must finish in time");
|
||||
org.junit.jupiter.api.Assertions.assertNull(firstFailure.get(), "no worker may throw");
|
||||
org.junit.jupiter.api.Assertions.assertEquals(
|
||||
1,
|
||||
maxConcurrent.get(),
|
||||
"mutual exclusion violated: more than one holder observed simultaneously");
|
||||
assertTrue(
|
||||
acquires.get() > 0,
|
||||
"at least some acquires must succeed under contention (saw "
|
||||
+ acquires.get()
|
||||
+ ")");
|
||||
}
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
package stirling.software.common.cluster.inprocess;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.cluster.ClusterNode;
|
||||
|
||||
class InProcessInstanceRegistryTest {
|
||||
|
||||
@Test
|
||||
void registerThenLookupAndActiveNodes() {
|
||||
InProcessInstanceRegistry registry = new InProcessInstanceRegistry();
|
||||
ClusterNode node = new ClusterNode("node-1", "127.0.0.1:8080", Instant.now(), "BOTH");
|
||||
registry.register(node, Duration.ofSeconds(30));
|
||||
|
||||
assertTrue(registry.lookup("node-1").isPresent());
|
||||
assertEquals("node-1", registry.lookup("node-1").get().nodeId());
|
||||
assertEquals(1, registry.activeNodes().size());
|
||||
|
||||
registry.deregister("node-1");
|
||||
assertTrue(registry.lookup("node-1").isEmpty());
|
||||
}
|
||||
}
|
||||
-91
@@ -1,91 +0,0 @@
|
||||
package stirling.software.common.cluster.inprocess;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.cluster.JobStoreEntry;
|
||||
|
||||
class InProcessJobStoreTest {
|
||||
|
||||
private final InProcessJobStore store = new InProcessJobStore();
|
||||
|
||||
@Test
|
||||
void putGetDeleteExistsRoundTrip() {
|
||||
JobStoreEntry entry = entry("job-1");
|
||||
store.put(entry, Duration.ofMinutes(30));
|
||||
|
||||
assertTrue(store.exists("job-1"));
|
||||
assertEquals(entry, store.get("job-1").orElseThrow());
|
||||
|
||||
store.delete("job-1");
|
||||
assertFalse(store.exists("job-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ttlExpiry() throws InterruptedException {
|
||||
store.put(entry("job-2"), Duration.ofMillis(50));
|
||||
Thread.sleep(100);
|
||||
assertFalse(store.get("job-2").isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void purgeExpiredRemovesOnlyStaleEntries() throws InterruptedException {
|
||||
store.put(entry("job-fresh"), Duration.ofMinutes(30));
|
||||
store.put(entry("job-stale"), Duration.ofMillis(20));
|
||||
Thread.sleep(80);
|
||||
|
||||
int removed = store.purgeExpired();
|
||||
assertEquals(1, removed);
|
||||
assertTrue(store.exists("job-fresh"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findJobIdByFileIdReturnsTheRightJob() {
|
||||
store.put(
|
||||
new JobStoreEntry(
|
||||
"job-a",
|
||||
JobStoreEntry.JobState.COMPLETE,
|
||||
"node-1",
|
||||
Instant.now(),
|
||||
Instant.now(),
|
||||
null,
|
||||
List.of("file-1", "file-2"),
|
||||
Map.of()),
|
||||
Duration.ofMinutes(30));
|
||||
store.put(
|
||||
new JobStoreEntry(
|
||||
"job-b",
|
||||
JobStoreEntry.JobState.COMPLETE,
|
||||
"node-1",
|
||||
Instant.now(),
|
||||
Instant.now(),
|
||||
null,
|
||||
List.of("file-3"),
|
||||
Map.of()),
|
||||
Duration.ofMinutes(30));
|
||||
|
||||
assertEquals("job-a", store.findJobIdByFileId("file-1").orElseThrow());
|
||||
assertEquals("job-b", store.findJobIdByFileId("file-3").orElseThrow());
|
||||
assertFalse(store.findJobIdByFileId("missing").isPresent());
|
||||
}
|
||||
|
||||
private JobStoreEntry entry(String id) {
|
||||
return new JobStoreEntry(
|
||||
id,
|
||||
JobStoreEntry.JobState.PENDING,
|
||||
"node-1",
|
||||
Instant.now(),
|
||||
null,
|
||||
null,
|
||||
List.of(),
|
||||
Map.of());
|
||||
}
|
||||
}
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
package stirling.software.common.cluster.inprocess;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.cluster.KeyValueCache;
|
||||
|
||||
class InProcessKeyValueCacheTest {
|
||||
|
||||
@Test
|
||||
void putGetEvict() {
|
||||
KeyValueCache cache = new InProcessKeyValueCache();
|
||||
cache.put("apikey", "a", "userA", Duration.ofMinutes(1));
|
||||
assertEquals("userA", cache.get("apikey", "a").orElseThrow());
|
||||
|
||||
cache.evict("apikey", "a");
|
||||
assertFalse(cache.get("apikey", "a").isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ttlExpiry() throws InterruptedException {
|
||||
KeyValueCache cache = new InProcessKeyValueCache();
|
||||
cache.put("ns", "k", "v", Duration.ofMillis(40));
|
||||
Thread.sleep(80);
|
||||
assertFalse(cache.get("ns", "k").isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void evictNamespace() {
|
||||
KeyValueCache cache = new InProcessKeyValueCache();
|
||||
cache.put("ns", "a", "1", Duration.ofMinutes(1));
|
||||
cache.put("ns", "b", "2", Duration.ofMinutes(1));
|
||||
cache.evictNamespace("ns");
|
||||
assertFalse(cache.get("ns", "a").isPresent());
|
||||
assertFalse(cache.get("ns", "b").isPresent());
|
||||
}
|
||||
}
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
package stirling.software.common.cluster.inprocess;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.cluster.RateLimitStore;
|
||||
import stirling.software.common.cluster.RateLimitStore.RateLimitDecision;
|
||||
|
||||
class InProcessRateLimitStoreTest {
|
||||
|
||||
@Test
|
||||
void firstNConsumesAllowed() {
|
||||
RateLimitStore store = new InProcessRateLimitStore();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
assertTrue(store.tryConsume("k", 5, Duration.ofSeconds(60)).allowed(), "i=" + i);
|
||||
}
|
||||
assertFalse(store.tryConsume("k", 5, Duration.ofSeconds(60)).allowed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void remainingTokensDecrements() {
|
||||
RateLimitStore store = new InProcessRateLimitStore();
|
||||
RateLimitDecision d1 = store.tryConsume("k", 5, Duration.ofSeconds(60));
|
||||
RateLimitDecision d2 = store.tryConsume("k", 5, Duration.ofSeconds(60));
|
||||
assertTrue(d1.allowed());
|
||||
assertTrue(d2.allowed());
|
||||
assertEquals(4, d1.remainingTokens());
|
||||
assertEquals(3, d2.remainingTokens());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refillRestoresTokens() throws InterruptedException {
|
||||
RateLimitStore store = new InProcessRateLimitStore();
|
||||
// Capacity 2 with smooth refill over 100 ms -> ~1 token per 50 ms.
|
||||
for (int i = 0; i < 2; i++) {
|
||||
assertTrue(store.tryConsume("k", 2, Duration.ofMillis(100)).allowed());
|
||||
}
|
||||
assertFalse(store.tryConsume("k", 2, Duration.ofMillis(100)).allowed());
|
||||
Thread.sleep(150);
|
||||
assertTrue(store.tryConsume("k", 2, Duration.ofMillis(100)).allowed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deniedConsumeReportsWaitNanos() {
|
||||
RateLimitStore store = new InProcessRateLimitStore();
|
||||
assertTrue(store.tryConsume("wait", 1, Duration.ofSeconds(10)).allowed());
|
||||
RateLimitDecision denied = store.tryConsume("wait", 1, Duration.ofSeconds(10));
|
||||
assertFalse(denied.allowed());
|
||||
assertTrue(denied.nanosToWaitForRefill() > 0L);
|
||||
}
|
||||
}
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
package stirling.software.common.cluster.inprocess;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import stirling.software.common.cluster.FileStore;
|
||||
|
||||
class LocalDiskFileStoreTest {
|
||||
|
||||
@Test
|
||||
void storeRetrieveSizeDeleteExistsRoundTrip(@TempDir Path dir) throws IOException {
|
||||
LocalDiskFileStore store = new LocalDiskFileStore(dir.toString());
|
||||
byte[] payload = "hello-bytes".getBytes();
|
||||
|
||||
FileStore.Stored stored = store.store(new ByteArrayInputStream(payload), "x.txt");
|
||||
assertEquals(payload.length, stored.size());
|
||||
assertTrue(store.exists(stored.fileId()));
|
||||
assertEquals(payload.length, store.size(stored.fileId()));
|
||||
assertArrayEquals(payload, store.retrieveBytes(stored.fileId()));
|
||||
|
||||
assertTrue(store.delete(stored.fileId()));
|
||||
assertFalse(store.exists(stored.fileId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void traversalIdsAreRejected(@TempDir Path dir) {
|
||||
LocalDiskFileStore store = new LocalDiskFileStore(dir.toString());
|
||||
assertThrows(IllegalArgumentException.class, () -> store.resolve("../foo"));
|
||||
assertThrows(IllegalArgumentException.class, () -> store.resolve("a/b"));
|
||||
assertThrows(IllegalArgumentException.class, () -> store.resolve("a\\b"));
|
||||
}
|
||||
}
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import stirling.software.common.cluster.inprocess.LocalDiskFileStore;
|
||||
|
||||
class FileStorageDelegationTest {
|
||||
|
||||
@Test
|
||||
void storeBytesThenRetrieveBytesRoundTripsThroughFileStore(@TempDir Path tempDir)
|
||||
throws IOException {
|
||||
FileStorage fs =
|
||||
new FileStorage(
|
||||
mock(FileOrUploadService.class),
|
||||
new LocalDiskFileStore(tempDir.toString()));
|
||||
byte[] payload = "round-trip".getBytes();
|
||||
String id = fs.storeBytes(payload, "x.bin");
|
||||
assertArrayEquals(payload, fs.retrieveBytes(id));
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package stirling.software.common.service;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -14,30 +13,29 @@ import java.util.stream.Stream;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.common.cluster.inprocess.LocalDiskFileStore;
|
||||
|
||||
class FileStorageTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@Mock private FileOrUploadService fileOrUploadService;
|
||||
|
||||
private FileStorage fileStorage;
|
||||
@InjectMocks private FileStorage fileStorage;
|
||||
|
||||
private MultipartFile mockFile;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
fileStorage =
|
||||
new FileStorage(fileOrUploadService, new LocalDiskFileStore(tempDir.toString()));
|
||||
ReflectionTestUtils.setField(fileStorage, "tempDirPath", tempDir.toString());
|
||||
|
||||
// Create a mock MultipartFile
|
||||
mockFile = mock(MultipartFile.class);
|
||||
@@ -49,7 +47,17 @@ class FileStorageTest {
|
||||
void testStoreFile() throws IOException {
|
||||
// Arrange
|
||||
byte[] fileContent = "Test PDF content".getBytes();
|
||||
when(mockFile.getInputStream()).thenReturn(new ByteArrayInputStream(fileContent));
|
||||
when(mockFile.getBytes()).thenReturn(fileContent);
|
||||
|
||||
// Set up mock to handle transferTo by writing the file
|
||||
doAnswer(
|
||||
invocation -> {
|
||||
java.io.File file = invocation.getArgument(0);
|
||||
Files.write(file.toPath(), fileContent);
|
||||
return null;
|
||||
})
|
||||
.when(mockFile)
|
||||
.transferTo(any(java.io.File.class));
|
||||
|
||||
// Act
|
||||
String fileId = fileStorage.storeFile(mockFile);
|
||||
@@ -57,7 +65,7 @@ class FileStorageTest {
|
||||
// Assert
|
||||
assertNotNull(fileId);
|
||||
assertTrue(Files.exists(tempDir.resolve(fileId)));
|
||||
assertArrayEquals(fileContent, Files.readAllBytes(tempDir.resolve(fileId)));
|
||||
verify(mockFile).transferTo(any(java.io.File.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -239,11 +247,11 @@ class FileStorageTest {
|
||||
filesBefore = s.count();
|
||||
}
|
||||
|
||||
// Act + Assert: IOException must propagate out - not be swallowed.
|
||||
// Act + Assert: IOException must propagate out — not be swallowed.
|
||||
assertThrows(
|
||||
IOException.class, () -> fileStorage.storeFromResource(flakyResource, "n.pdf"));
|
||||
|
||||
// Assert: no partial file lingers under the storage directory - the finally
|
||||
// Assert: no partial file lingers under the storage directory — the finally
|
||||
// branch's deleteIfExists must have cleaned it up.
|
||||
long filesAfter;
|
||||
try (Stream<Path> s = Files.list(tempDir)) {
|
||||
|
||||
-120
@@ -1,120 +0,0 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.cluster.ClusterBackplane;
|
||||
import stirling.software.common.cluster.JobStoreEntry;
|
||||
import stirling.software.common.cluster.JobStoreEntry.JobState;
|
||||
import stirling.software.common.cluster.inprocess.InProcessClusterBackplane;
|
||||
import stirling.software.common.cluster.inprocess.InProcessJobStore;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.job.JobResult;
|
||||
|
||||
class TaskManagerJobStoreDelegationTest {
|
||||
|
||||
@Mock private FileStorage fileStorage;
|
||||
|
||||
private InProcessJobStore jobStore;
|
||||
private ClusterBackplane backplane;
|
||||
private TaskManager taskManager;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
jobStore = spy(new InProcessJobStore());
|
||||
backplane = new InProcessClusterBackplane(new ApplicationProperties());
|
||||
taskManager = new TaskManager(fileStorage, jobStore, backplane);
|
||||
ReflectionTestUtils.setField(taskManager, "jobResultExpiryMinutes", 30);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createTaskWritesPendingEntry() {
|
||||
taskManager.createTask("job-1");
|
||||
JobStoreEntry entry = jobStore.get("job-1").orElseThrow();
|
||||
assertEquals(JobState.PENDING, entry.state());
|
||||
assertEquals(backplane.localNodeId(), entry.owningNodeId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setCompleteFlipsToComplete() {
|
||||
taskManager.createTask("job-2");
|
||||
taskManager.setResult("job-2", "ok");
|
||||
taskManager.setComplete("job-2");
|
||||
JobStoreEntry entry = jobStore.get("job-2").orElseThrow();
|
||||
assertEquals(JobState.COMPLETE, entry.state());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setErrorFlipsToFailed() {
|
||||
taskManager.createTask("job-3");
|
||||
taskManager.setError("job-3", "boom");
|
||||
JobStoreEntry entry = jobStore.get("job-3").orElseThrow();
|
||||
assertEquals(JobState.FAILED, entry.state());
|
||||
assertEquals("boom", entry.error());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupOldJobsIsNoopWhenBackplaneIsNotInProcess() {
|
||||
ClusterBackplane mockedValkeyBackplane =
|
||||
new ClusterBackplane() {
|
||||
@Override
|
||||
public boolean isHealthy() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String backplaneType() {
|
||||
return "valkey";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String localNodeId() {
|
||||
return "node-1";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldRunLocalCleanup() {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
TaskManager tm = new TaskManager(fileStorage, jobStore, mockedValkeyBackplane);
|
||||
ReflectionTestUtils.setField(tm, "jobResultExpiryMinutes", 30);
|
||||
tm.createTask("job-4");
|
||||
tm.setComplete("job-4");
|
||||
ageJobPastExpiry(tm, "job-4");
|
||||
tm.cleanupOldJobs();
|
||||
// cleanup must short-circuit before touching jobStore in cluster mode; the backplane
|
||||
// TTL owns expiry there. If the gate fired correctly, delete is never called.
|
||||
verify(jobStore, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupOldJobsDeletesFromJobStoreWhenBackplaneIsInProcess() {
|
||||
taskManager.createTask("job-5");
|
||||
taskManager.setComplete("job-5");
|
||||
ageJobPastExpiry(taskManager, "job-5");
|
||||
taskManager.cleanupOldJobs();
|
||||
verify(jobStore).delete("job-5");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void ageJobPastExpiry(TaskManager tm, String jobId) {
|
||||
var jobResults =
|
||||
(java.util.Map<String, JobResult>) ReflectionTestUtils.getField(tm, "jobResults");
|
||||
JobResult result = jobResults.get(jobId);
|
||||
ReflectionTestUtils.setField(result, "completedAt", LocalDateTime.now().minusHours(2));
|
||||
ReflectionTestUtils.setField(result, "complete", true);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,20 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.cluster.ClusterBackplane;
|
||||
import stirling.software.common.cluster.JobStore;
|
||||
import stirling.software.common.cluster.JobStoreEntry;
|
||||
import stirling.software.common.cluster.JobStoreEntry.JobState;
|
||||
import stirling.software.common.model.job.JobResult;
|
||||
import stirling.software.common.model.job.JobStats;
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
@@ -30,8 +22,6 @@ import stirling.software.common.model.job.ResultFile;
|
||||
class TaskManagerTest {
|
||||
|
||||
@Mock private FileStorage fileStorage;
|
||||
@Mock private JobStore jobStore;
|
||||
@Mock private ClusterBackplane clusterBackplane;
|
||||
|
||||
@InjectMocks private TaskManager taskManager;
|
||||
|
||||
@@ -40,10 +30,6 @@ class TaskManagerTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
closeable = MockitoAnnotations.openMocks(this);
|
||||
// Treat the backplane as in-process so cleanupOldJobs is not short-circuited.
|
||||
lenient().when(clusterBackplane.backplaneType()).thenReturn("inprocess");
|
||||
lenient().when(clusterBackplane.localNodeId()).thenReturn("test-node");
|
||||
lenient().when(clusterBackplane.shouldRunLocalCleanup()).thenReturn(true);
|
||||
ReflectionTestUtils.setField(taskManager, "jobResultExpiryMinutes", 30);
|
||||
}
|
||||
|
||||
@@ -284,33 +270,6 @@ class TaskManagerTest {
|
||||
verify(fileStorage).deleteFile("file-id");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCleanupOldJobs_NoOpWhenBackplaneOwnsExpiry() {
|
||||
// When the backplane reports it should NOT run local cleanup (e.g. a distributed
|
||||
// backplane with its own TTL), the cleanup loop must leave local state untouched.
|
||||
when(clusterBackplane.shouldRunLocalCleanup()).thenReturn(false);
|
||||
|
||||
// Seed an old completed job that would normally be removed.
|
||||
String oldJobId = "old-job-distributed";
|
||||
taskManager.createTask(oldJobId);
|
||||
JobResult oldJob = taskManager.getJobResult(oldJobId);
|
||||
ReflectionTestUtils.setField(oldJob, "completedAt", LocalDateTime.now().minusHours(1));
|
||||
ReflectionTestUtils.setField(oldJob, "complete", true);
|
||||
|
||||
Map<String, JobResult> jobResultsMap =
|
||||
(Map<String, JobResult>) ReflectionTestUtils.getField(taskManager, "jobResults");
|
||||
assertNotNull(jobResultsMap);
|
||||
assertTrue(jobResultsMap.containsKey(oldJobId));
|
||||
|
||||
// Act
|
||||
taskManager.cleanupOldJobs();
|
||||
|
||||
// Assert: nothing was removed locally, and no jobStore.delete was issued.
|
||||
assertTrue(jobResultsMap.containsKey(oldJobId));
|
||||
verify(jobStore, never()).delete(anyString());
|
||||
verify(fileStorage, never()).deleteFile(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShutdown() {
|
||||
// This mainly tests that the shutdown method doesn't throw exceptions
|
||||
@@ -351,33 +310,4 @@ class TaskManagerTest {
|
||||
// Assert
|
||||
assertFalse(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testWriteThroughOnUpdate() {
|
||||
// Mutating calls must write through to the injected JobStore.
|
||||
String jobId = "write-through-job";
|
||||
taskManager.createTask(jobId);
|
||||
taskManager.setResult(jobId, "done");
|
||||
|
||||
ArgumentCaptor<JobStoreEntry> captor = ArgumentCaptor.forClass(JobStoreEntry.class);
|
||||
verify(jobStore, atLeast(2)).put(captor.capture(), any());
|
||||
|
||||
JobStoreEntry last = captor.getValue();
|
||||
assertEquals(jobId, last.jobId());
|
||||
assertEquals(JobState.COMPLETE, last.state());
|
||||
assertEquals("test-node", last.owningNodeId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFindJobKeyByFileId_FallsBackToJobStore() {
|
||||
// When the file id is not in the local map, TaskManager delegates to JobStore.
|
||||
String fileId = "remote-file-id";
|
||||
String expectedJobKey = "remote-job-key";
|
||||
when(jobStore.findJobIdByFileId(fileId)).thenReturn(Optional.of(expectedJobKey));
|
||||
|
||||
String actual = taskManager.findJobKeyByFileId(fileId);
|
||||
|
||||
assertEquals(expectedJobKey, actual);
|
||||
verify(jobStore).findJobIdByFileId(fileId);
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -277,6 +277,9 @@ public class ConfigController {
|
||||
// Premium/Enterprise settings
|
||||
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
|
||||
|
||||
// AI Engine settings
|
||||
configData.put("aiEngineEnabled", applicationProperties.getAiEngine().isEnabled());
|
||||
|
||||
// Timestamp TSA settings — single source of truth for presets + admin URLs
|
||||
ApplicationProperties.Security.Timestamp tsConfig =
|
||||
applicationProperties.getSecurity().getTimestamp();
|
||||
|
||||
@@ -330,22 +330,6 @@ aiEngine:
|
||||
url: http://localhost:5001 # URL of the Python AI engine
|
||||
timeoutSeconds: 120 # Timeout in seconds for AI engine requests
|
||||
|
||||
# Cluster configuration. NOT YET ENABLED - scaffolding for later work. Leave at defaults.
|
||||
cluster:
|
||||
enabled: false # Master switch. 'false' (default) wires the in-process backplane and skips all cluster checks. Single-instance installs do not need to change anything here.
|
||||
backplane: inprocess # Backplane implementation: 'inprocess' (single JVM only) or 'valkey' (multi-node via Valkey/Redis)
|
||||
artifactStore: local # Transient cluster job-artifact backend: 'local' (per-node disk; single-node only) or 's3' (shared object store; required for multi-node). Distinct from 'storage.provider' which controls persistent user uploads - when both are 's3' they share the storage.s3.* credentials block. Multi-node deployments MUST set this to 's3'.
|
||||
valkey:
|
||||
url: "" # Valkey/Redis URL, e.g. 'redis://valkey:6379' or 'rediss://...' for TLS. Required when enabled=true and backplane=valkey.
|
||||
tls:
|
||||
skipCertVerification: false # set to 'true' to skip TLS certificate verification on Valkey connections (dev/test only)
|
||||
node:
|
||||
id: "" # Optional explicit node id. Blank = auto-generated UUID at startup.
|
||||
role: both # 'web' (serves HTTP), 'worker' (runs jobs), or 'both' (default)
|
||||
internalAddress: "" # host:port advertised in the instance registry for peer-to-peer cluster traffic. Blank = derived at startup.
|
||||
scheme: http # 'http' or 'https' - scheme peers use to call this node's /internal/cluster/** endpoints
|
||||
heartbeatIntervalMs: 5000 # Heartbeat publish interval for the instance registry (ms)
|
||||
|
||||
pdfEditor:
|
||||
fallback-font: classpath:/static/fonts/NotoSans-Regular.ttf # Override to point at a custom fallback font
|
||||
cache:
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
# AWS deployment - Stirling-PDF clustered
|
||||
|
||||
Three paths, ordered by ease-of-use:
|
||||
|
||||
| Path | Best for | Time-to-live | Monthly cost (us-east-1) | Scales? |
|
||||
|---|---|---|---|---|
|
||||
| **1. CloudFormation one-click** | Enterprise self-host, no Kubernetes | ~15 min | ~$120-160 | Yes (ECS autoscaling) |
|
||||
| **2. Terraform module** | Your SaaS, env-promotable, GitOps | ~20 min first time | ~$120-160 | Yes (ECS autoscaling) |
|
||||
| **3. EC2 + Docker Compose** | ≤25 concurrent users, single VM, dead simple | ~5 min | ~$25-40 | No (one VM) |
|
||||
|
||||
Already have a Kubernetes cluster (EKS)? Use the existing
|
||||
[`deploy/helm/stirling-pdf/`](../helm/stirling-pdf/) chart instead of any of
|
||||
these.
|
||||
|
||||
## What each deploys
|
||||
|
||||
All three deliver the same logical topology: **N Stirling app instances behind
|
||||
a load balancer, sharing a Valkey backplane and a Postgres database**, with
|
||||
`/internal/*` blocked at the LB.
|
||||
|
||||
```
|
||||
Internet
|
||||
│
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ ALB / nginx │ ←── blocks /internal/*
|
||||
└────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────────┐
|
||||
│ app-1 app-2 │ ←── CLUSTER_ENABLED=true
|
||||
└────────────────┘ CLUSTER_BACKPLANE=valkey
|
||||
│ │
|
||||
▼ ▼
|
||||
Valkey Postgres
|
||||
(ElastiCache) (RDS)
|
||||
```
|
||||
|
||||
### Sticky sessions are required
|
||||
|
||||
Every LB config in this repo (`ip_hash` in nginx, `lb_cookie` on the ALB,
|
||||
`affinity: cookie` on the k8s ingress) is pinning sessions deliberately, not as
|
||||
an optimisation. Result PDFs are written to the local disk of whichever node
|
||||
ran the job; without affinity a download has a ~50% chance of landing on a
|
||||
non-owner node and getting a 410 Gone. Cookie / IP affinity pins a returning
|
||||
client back to the owner pod. If you fork an LB config, keep the stickiness.
|
||||
|
||||
**Heads-up on `ip_hash` (nginx Docker Compose path only):** `ip_hash` collapses
|
||||
every client sharing a source IP onto the same backend. Behind a corporate VPN,
|
||||
CGNAT, or a single egress NAT this means all of those users hammer one app
|
||||
container while the others sit idle. The Compose path is fine for small
|
||||
deployments (the doc above caps it at 25 concurrent users on one VM), but for
|
||||
diverse client populations move to cookie-based affinity (nginx-plus or the
|
||||
openresty sticky-cookie module) or use one of the managed LB paths above
|
||||
- the ALB / k8s Ingress configs in this directory already use cookie stickiness
|
||||
for exactly this reason.
|
||||
|
||||
The only thing that changes between paths is **who manages Valkey and Postgres**:
|
||||
|
||||
| Path | App tasks | Valkey | Postgres | LB |
|
||||
|---|---|---|---|---|
|
||||
| CloudFormation | ECS Fargate | ElastiCache for Valkey | RDS PostgreSQL | ALB |
|
||||
| Terraform | ECS Fargate | ElastiCache for Valkey | RDS PostgreSQL | ALB |
|
||||
| EC2 Compose | Docker on one EC2 | Docker container | Docker container | nginx (Docker) |
|
||||
|
||||
## Path 1 - CloudFormation (recommended for enterprises)
|
||||
|
||||
The template no longer accepts plaintext passwords as parameters - that pattern leaked
|
||||
secrets into the rendered stack template, CloudTrail events, and the template S3 bucket
|
||||
even with `NoEcho`. Operators pre-create the password secrets and pass their ARNs:
|
||||
|
||||
```bash
|
||||
DB_SECRET_ARN=$(aws secretsmanager create-secret \
|
||||
--name stirling/db-password \
|
||||
--secret-string "$(openssl rand -base64 24)" \
|
||||
--query ARN --output text)
|
||||
|
||||
VALKEY_SECRET_ARN=$(aws secretsmanager create-secret \
|
||||
--name stirling/valkey-auth \
|
||||
--secret-string "$(openssl rand -hex 32)" \
|
||||
--query ARN --output text)
|
||||
|
||||
aws cloudformation deploy \
|
||||
--stack-name stirling-pdf \
|
||||
--template-file cloudformation/stirling-pdf-aws.yaml \
|
||||
--capabilities CAPABILITY_NAMED_IAM \
|
||||
--parameter-overrides \
|
||||
DbSecretArn=$DB_SECRET_ARN \
|
||||
ValkeyAuthSecretArn=$VALKEY_SECRET_ARN
|
||||
```
|
||||
|
||||
The X-Engine-Auth token is generated *inside* Secrets Manager by CloudFormation - the
|
||||
operator never sees or supplies it. After deploy it can be retrieved with
|
||||
`aws secretsmanager get-secret-value --secret-id <stack-name>-engine-secret`.
|
||||
|
||||
Or click-deploy via the AWS console: **Console → CloudFormation → Create stack
|
||||
→ Upload `cloudformation/stirling-pdf-aws.yaml` → fill the 2 ARN params**.
|
||||
|
||||
The stack output `AppUrl` gives you the URL.
|
||||
|
||||
**Knobs you can change in the parameters:**
|
||||
|
||||
| Param | Default | Notes |
|
||||
|---|---|---|
|
||||
| `AppCount` | 2 | Initial Fargate task count; autoscaling can grow to 10 |
|
||||
| `AppCpu` | 1024 (1 vCPU) | Per task |
|
||||
| `AppMemory` | 4096 MiB | Per task |
|
||||
| `EnableAiEngine` | false | Turn on if using AI features |
|
||||
| `AppImage` | `stirlingtools/stirling-pdf:2.11.0` | Pinned to release. Swap for your own ECR URI or newer tag |
|
||||
| `EngineImage` | `stirlingtools/stirling-pdf-ai-engine:2.11.0` | Same |
|
||||
| `DbSecretArn` | (required) | ARN of pre-created Secrets Manager secret holding the Postgres password |
|
||||
| `ValkeyAuthSecretArn` | (required) | ARN of pre-created Secrets Manager secret holding the Valkey AUTH token |
|
||||
|
||||
**Tear-down:** `aws cloudformation delete-stack --stack-name stirling-pdf`. RDS
|
||||
keeps a final snapshot for safety.
|
||||
|
||||
## Path 2 - Terraform (recommended for your SaaS)
|
||||
|
||||
```bash
|
||||
cd terraform
|
||||
cat > terraform.tfvars <<EOF
|
||||
name = "stirling-prod"
|
||||
region = "us-east-1"
|
||||
app_count = 3
|
||||
db_password = "$(openssl rand -base64 24)"
|
||||
engine_shared_secret = "$(openssl rand -hex 32)"
|
||||
valkey_auth_token = "$(openssl rand -hex 32)"
|
||||
EOF
|
||||
terraform init
|
||||
terraform apply
|
||||
```
|
||||
|
||||
The ElastiCache replication group runs with TLS in flight (`rediss://`) and AUTH enabled.
|
||||
`valkey_auth_token` is required - the application connects with `REDIS_PASSWORD` populated
|
||||
from Secrets Manager so a compromised pod cannot sweep the keyspace.
|
||||
|
||||
Tearing down: `terraform destroy`.
|
||||
|
||||
The Terraform module is intentionally a **single file** to keep it easy to
|
||||
fork. For real production you'd split into `modules/{vpc,ecs,rds,elasticache,alb}`
|
||||
and have a separate `envs/{dev,staging,prod}/main.tf` referencing them. Both
|
||||
shapes work; the single file is the starting point.
|
||||
|
||||
## Path 3 - EC2 + Docker Compose
|
||||
|
||||
See [`quickstart/ec2-compose.md`](quickstart/ec2-compose.md).
|
||||
|
||||
## EKS + Helm (for k8s shops)
|
||||
|
||||
The Phase 1 implementation already ships a Helm chart:
|
||||
|
||||
```bash
|
||||
# Assumes you already have an EKS cluster
|
||||
helm install stirling deploy/helm/stirling-pdf/ \
|
||||
--set cluster.engineSharedSecret=$(openssl rand -hex 32) \
|
||||
--set image.repository=<your-ecr-uri>/stirling-pdf
|
||||
```
|
||||
|
||||
The chart includes a Valkey StatefulSet by default. To use ElastiCache instead:
|
||||
|
||||
```bash
|
||||
helm install stirling deploy/helm/stirling-pdf/ \
|
||||
--set cluster.valkey.bundled=false \
|
||||
--set cluster.valkey.externalUrl=redis://your-elasticache:6379 \
|
||||
...
|
||||
```
|
||||
|
||||
## Required AWS permissions (for whoever runs the deploy)
|
||||
|
||||
Minimum set to apply the CloudFormation/Terraform:
|
||||
|
||||
- `ec2:*`, `elasticloadbalancing:*` (VPC + ALB)
|
||||
- `ecs:*`, `iam:CreateRole`, `iam:AttachRolePolicy`, `iam:PutRolePolicy`,
|
||||
`iam:PassRole`
|
||||
- `elasticache:*`
|
||||
- `rds:*`
|
||||
- `secretsmanager:*`
|
||||
- `logs:CreateLogGroup`, `logs:PutRetentionPolicy`
|
||||
- `application-autoscaling:*`
|
||||
- `cloudformation:*` (CFN only)
|
||||
|
||||
Easiest: run as a user with `PowerUserAccess` for the initial bootstrap, then
|
||||
narrow down with IAM Access Analyzer after the stack is up.
|
||||
|
||||
## Sizing rules of thumb
|
||||
|
||||
| Concurrent users | App tasks | App size | Valkey | Postgres |
|
||||
|---|---|---|---|---|
|
||||
| ≤25 | 2 | 1 vCPU / 4 GB | cache.t4g.small | db.t4g.micro |
|
||||
| 25-100 | 3 | 2 vCPU / 4 GB | cache.t4g.small | db.t4g.small |
|
||||
| 100-500 | 4-6 | 2 vCPU / 8 GB | cache.t4g.medium | db.t4g.medium |
|
||||
| 500+ | 6+ (autoscale) | 2 vCPU / 8 GB | cache.r7g.large + replica | db.t4g.large + read replica |
|
||||
|
||||
The defaults in the templates suit ≤25 users; bump `AppCount` /
|
||||
`InstanceClass` to grow.
|
||||
|
||||
## What you still need to set up yourself
|
||||
|
||||
These are not in the templates because they're customer-specific:
|
||||
|
||||
- **Custom domain + HTTPS.** ALB listener on port 443 with an ACM cert (5-min
|
||||
console wizard) + a Route 53 A-record alias to the ALB.
|
||||
- **Email (SES) for password reset / invitations.**
|
||||
- **OAuth/SAML identity provider.** Stirling supports Keycloak, Okta, Azure
|
||||
AD, Google - config goes in `settings.yml`.
|
||||
- **Backups for Valkey.** Optional - ElastiCache supports daily snapshots; turn
|
||||
on `SnapshotRetentionLimit` in the template if you want. Phase 1 state in
|
||||
Valkey is short-TTL so most operators skip it.
|
||||
- **Frontend CDN.** Not required; the app serves static assets fine. If you
|
||||
want CloudFront, point it at the ALB and cache `/static/*`.
|
||||
|
||||
## Common questions
|
||||
|
||||
**Why ECS Fargate, not EKS?** Fargate has zero cluster management - no
|
||||
control plane to maintain. EKS gets cheaper at scale but adds k8s ops. For
|
||||
most enterprises Fargate is the right default.
|
||||
|
||||
**Why ElastiCache for Valkey and not just Redis?** Valkey is the Linux
|
||||
Foundation's BSD-licensed Redis fork; AWS ElastiCache has supported it natively
|
||||
since 2024. Same wire protocol. Stirling's `LettuceConnectionFactory` doesn't
|
||||
care which one - pick whichever your security team is happier with.
|
||||
|
||||
**Why RDS Postgres and not Aurora?** Aurora costs ~3× more for the same TPS in
|
||||
this workload (mostly cold reads for user/team tables). Switch to Aurora if
|
||||
you need read replicas + DR; t4g.micro covers the small-tenant case.
|
||||
|
||||
**What if I want to use my company's existing Postgres / Redis?** Run only
|
||||
the ECS part of the CloudFormation by setting `SPRING_DATASOURCE_URL` and
|
||||
`CLUSTER_VALKEY_URL` in the task definition to point at your
|
||||
existing endpoints. The template doesn't currently expose those as parameters
|
||||
- easy fork.
|
||||
|
||||
**My ops team wants Pulumi / CDK / Crossplane.** All three speak the same
|
||||
underlying APIs the CloudFormation template uses. Translate from the YAML -
|
||||
the resource graph is identical.
|
||||
@@ -1,507 +0,0 @@
|
||||
AWSTemplateFormatVersion: "2010-09-09"
|
||||
Description: >
|
||||
Stirling-PDF clustered deployment on AWS. Provisions an ECS Fargate service
|
||||
(2+ app tasks) + ElastiCache for Valkey + RDS PostgreSQL + ALB. Single
|
||||
CloudFormation stack - click Launch Stack and fill in 4 fields.
|
||||
|
||||
Cost estimate (us-east-1, default sizing): ~$120-160 / month.
|
||||
Tear-down: delete the stack. Everything except the RDS final snapshot goes.
|
||||
|
||||
Parameters:
|
||||
AppImage:
|
||||
Type: String
|
||||
Default: stirlingtools/stirling-pdf:2.11.0
|
||||
Description: Container image with the Phase 1 cluster code. Use your ECR URI for private builds. Pin to an exact version - never :latest in production.
|
||||
|
||||
EngineImage:
|
||||
Type: String
|
||||
Default: stirlingtools/stirling-pdf-ai-engine:2.11.0
|
||||
Description: AI engine image. Leave default if you do not use AI features. Pin to an exact version - never :latest in production.
|
||||
|
||||
DbSecretArn:
|
||||
Type: String
|
||||
AllowedPattern: "^arn:aws:secretsmanager:[a-z0-9-]+:[0-9]+:secret:.+$"
|
||||
Description: >
|
||||
ARN of a Secrets Manager secret holding the Postgres password as plain SecretString.
|
||||
Create: aws secretsmanager create-secret --name stirling/db-password --secret-string "$(openssl rand -base64 24)"
|
||||
Passing the ARN keeps plaintext out of the rendered template and CloudTrail.
|
||||
|
||||
ValkeyAuthSecretArn:
|
||||
Type: String
|
||||
AllowedPattern: "^arn:aws:secretsmanager:[a-z0-9-]+:[0-9]+:secret:.+$"
|
||||
Description: >
|
||||
ARN of a Secrets Manager secret holding the ElastiCache AUTH token (16-128 chars).
|
||||
Create: aws secretsmanager create-secret --name stirling/valkey-auth --secret-string "$(openssl rand -hex 32)"
|
||||
Passing the ARN keeps plaintext out of the rendered template and CloudTrail.
|
||||
|
||||
AppCount:
|
||||
Type: Number
|
||||
Default: 2
|
||||
MinValue: 2
|
||||
MaxValue: 10
|
||||
Description: Number of Stirling-PDF app tasks. Multi-instance requires at least 2.
|
||||
|
||||
AppCpu:
|
||||
Type: Number
|
||||
Default: 1024
|
||||
AllowedValues: [512, 1024, 2048, 4096]
|
||||
Description: Fargate CPU units per app task (1024 = 1 vCPU).
|
||||
|
||||
AppMemory:
|
||||
Type: Number
|
||||
Default: 4096
|
||||
AllowedValues: [1024, 2048, 4096, 8192]
|
||||
Description: Fargate memory MiB per app task.
|
||||
|
||||
EnableAiEngine:
|
||||
Type: String
|
||||
Default: "false"
|
||||
AllowedValues: ["true", "false"]
|
||||
Description: Set true to also run the AI engine service.
|
||||
|
||||
Conditions:
|
||||
WithAiEngine: !Equals [!Ref EnableAiEngine, "true"]
|
||||
|
||||
Resources:
|
||||
|
||||
# ----- networking: use default VPC + its subnets to stay simple -----
|
||||
Vpc:
|
||||
Type: AWS::EC2::VPC
|
||||
Properties:
|
||||
CidrBlock: 10.42.0.0/16
|
||||
EnableDnsHostnames: true
|
||||
EnableDnsSupport: true
|
||||
Tags: [{Key: Name, Value: !Sub "${AWS::StackName}-vpc"}]
|
||||
|
||||
Igw:
|
||||
Type: AWS::EC2::InternetGateway
|
||||
IgwAttach:
|
||||
Type: AWS::EC2::VPCGatewayAttachment
|
||||
Properties: {VpcId: !Ref Vpc, InternetGatewayId: !Ref Igw}
|
||||
|
||||
SubnetA:
|
||||
Type: AWS::EC2::Subnet
|
||||
Properties:
|
||||
VpcId: !Ref Vpc
|
||||
AvailabilityZone: !Select [0, !GetAZs ""]
|
||||
CidrBlock: 10.42.1.0/24
|
||||
MapPublicIpOnLaunch: true
|
||||
SubnetB:
|
||||
Type: AWS::EC2::Subnet
|
||||
Properties:
|
||||
VpcId: !Ref Vpc
|
||||
AvailabilityZone: !Select [1, !GetAZs ""]
|
||||
CidrBlock: 10.42.2.0/24
|
||||
MapPublicIpOnLaunch: true
|
||||
|
||||
PublicRt:
|
||||
Type: AWS::EC2::RouteTable
|
||||
Properties: {VpcId: !Ref Vpc}
|
||||
PublicRoute:
|
||||
Type: AWS::EC2::Route
|
||||
DependsOn: IgwAttach
|
||||
Properties:
|
||||
RouteTableId: !Ref PublicRt
|
||||
DestinationCidrBlock: 0.0.0.0/0
|
||||
GatewayId: !Ref Igw
|
||||
RtAssocA:
|
||||
Type: AWS::EC2::SubnetRouteTableAssociation
|
||||
Properties: {SubnetId: !Ref SubnetA, RouteTableId: !Ref PublicRt}
|
||||
RtAssocB:
|
||||
Type: AWS::EC2::SubnetRouteTableAssociation
|
||||
Properties: {SubnetId: !Ref SubnetB, RouteTableId: !Ref PublicRt}
|
||||
|
||||
# ----- security groups -----
|
||||
AlbSg:
|
||||
Type: AWS::EC2::SecurityGroup
|
||||
Properties:
|
||||
GroupDescription: Public ALB
|
||||
VpcId: !Ref Vpc
|
||||
SecurityGroupIngress:
|
||||
- {IpProtocol: tcp, FromPort: 80, ToPort: 80, CidrIp: 0.0.0.0/0}
|
||||
- {IpProtocol: tcp, FromPort: 443, ToPort: 443, CidrIp: 0.0.0.0/0}
|
||||
|
||||
AppSg:
|
||||
Type: AWS::EC2::SecurityGroup
|
||||
Properties:
|
||||
GroupDescription: Stirling app tasks
|
||||
VpcId: !Ref Vpc
|
||||
SecurityGroupIngress:
|
||||
- {IpProtocol: tcp, FromPort: 8080, ToPort: 8080, SourceSecurityGroupId: !Ref AlbSg}
|
||||
- {IpProtocol: tcp, FromPort: 8080, ToPort: 8080, SourceSecurityGroupId: !GetAtt InternalSelfSg.GroupId}
|
||||
|
||||
InternalSelfSg:
|
||||
Type: AWS::EC2::SecurityGroup
|
||||
Properties:
|
||||
GroupDescription: Allow app tasks to reach each other internally
|
||||
VpcId: !Ref Vpc
|
||||
|
||||
ValkeySg:
|
||||
Type: AWS::EC2::SecurityGroup
|
||||
Properties:
|
||||
GroupDescription: Valkey ElastiCache
|
||||
VpcId: !Ref Vpc
|
||||
SecurityGroupIngress:
|
||||
- {IpProtocol: tcp, FromPort: 6379, ToPort: 6379, SourceSecurityGroupId: !Ref AppSg}
|
||||
|
||||
DbSg:
|
||||
Type: AWS::EC2::SecurityGroup
|
||||
Properties:
|
||||
GroupDescription: PostgreSQL
|
||||
VpcId: !Ref Vpc
|
||||
SecurityGroupIngress:
|
||||
- {IpProtocol: tcp, FromPort: 5432, ToPort: 5432, SourceSecurityGroupId: !Ref AppSg}
|
||||
|
||||
EngineLbSg:
|
||||
Type: AWS::EC2::SecurityGroup
|
||||
Condition: WithAiEngine
|
||||
Properties:
|
||||
GroupDescription: Internal ALB in front of the engine tier - only reachable from app tasks
|
||||
VpcId: !Ref Vpc
|
||||
SecurityGroupIngress:
|
||||
- {IpProtocol: tcp, FromPort: 5001, ToPort: 5001, SourceSecurityGroupId: !Ref AppSg}
|
||||
|
||||
EngineSg:
|
||||
Type: AWS::EC2::SecurityGroup
|
||||
Condition: WithAiEngine
|
||||
Properties:
|
||||
GroupDescription: AI engine - only reachable from the internal engine LB
|
||||
VpcId: !Ref Vpc
|
||||
SecurityGroupIngress:
|
||||
- {IpProtocol: tcp, FromPort: 5001, ToPort: 5001, SourceSecurityGroupId: !Ref EngineLbSg}
|
||||
|
||||
# ----- managed Valkey -----
|
||||
ValkeySubnetGroup:
|
||||
Type: AWS::ElastiCache::SubnetGroup
|
||||
Properties:
|
||||
Description: Stirling Valkey subnet group
|
||||
SubnetIds: [!Ref SubnetA, !Ref SubnetB]
|
||||
|
||||
Valkey:
|
||||
Type: AWS::ElastiCache::ReplicationGroup
|
||||
Properties:
|
||||
ReplicationGroupDescription: Stirling Valkey
|
||||
Engine: valkey
|
||||
EngineVersion: "8.0"
|
||||
CacheNodeType: cache.t4g.small
|
||||
NumCacheClusters: 1
|
||||
AutomaticFailoverEnabled: false
|
||||
CacheSubnetGroupName: !Ref ValkeySubnetGroup
|
||||
SecurityGroupIds: [!Ref ValkeySg]
|
||||
AtRestEncryptionEnabled: true
|
||||
# TLS in flight required when AuthToken is set (ElastiCache enforces this).
|
||||
TransitEncryptionEnabled: true
|
||||
AuthToken: !Sub "{{resolve:secretsmanager:${ValkeyAuthSecretArn}}}"
|
||||
|
||||
# ----- managed Postgres -----
|
||||
DbSubnetGroup:
|
||||
Type: AWS::RDS::DBSubnetGroup
|
||||
Properties:
|
||||
DBSubnetGroupDescription: Stirling Postgres subnet group
|
||||
SubnetIds: [!Ref SubnetA, !Ref SubnetB]
|
||||
|
||||
Postgres:
|
||||
Type: AWS::RDS::DBInstance
|
||||
DeletionPolicy: Snapshot
|
||||
UpdateReplacePolicy: Snapshot
|
||||
Properties:
|
||||
DBInstanceIdentifier: !Sub "${AWS::StackName}-pg"
|
||||
AllocatedStorage: 20
|
||||
DBInstanceClass: db.t4g.micro
|
||||
Engine: postgres
|
||||
EngineVersion: "17.2"
|
||||
MasterUsername: stirling
|
||||
MasterUserPassword: !Sub "{{resolve:secretsmanager:${DbSecretArn}}}"
|
||||
DBName: stirling
|
||||
DBSubnetGroupName: !Ref DbSubnetGroup
|
||||
VPCSecurityGroups: [!Ref DbSg]
|
||||
StorageEncrypted: true
|
||||
PubliclyAccessible: false
|
||||
MultiAZ: false
|
||||
BackupRetentionPeriod: 7
|
||||
|
||||
# ----- secrets -----
|
||||
EngineSharedSecret:
|
||||
Type: AWS::SecretsManager::Secret
|
||||
Properties:
|
||||
Name: !Sub "${AWS::StackName}-engine-secret"
|
||||
Description: X-Engine-Auth shared secret between app and AI engine. Generated by CFN, never passed in.
|
||||
GenerateSecretString:
|
||||
PasswordLength: 64
|
||||
ExcludePunctuation: true
|
||||
|
||||
|
||||
# ----- ECS cluster + roles -----
|
||||
EcsCluster:
|
||||
Type: AWS::ECS::Cluster
|
||||
Properties:
|
||||
ClusterName: !Sub "${AWS::StackName}-cluster"
|
||||
ClusterSettings:
|
||||
- {Name: containerInsights, Value: enabled}
|
||||
|
||||
TaskExecutionRole:
|
||||
Type: AWS::IAM::Role
|
||||
Properties:
|
||||
AssumeRolePolicyDocument:
|
||||
Statement:
|
||||
- Effect: Allow
|
||||
Principal: {Service: ecs-tasks.amazonaws.com}
|
||||
Action: sts:AssumeRole
|
||||
ManagedPolicyArns:
|
||||
- arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
|
||||
Policies:
|
||||
- PolicyName: ReadSecrets
|
||||
PolicyDocument:
|
||||
Statement:
|
||||
- Effect: Allow
|
||||
Action: [secretsmanager:GetSecretValue]
|
||||
Resource:
|
||||
- !Ref EngineSharedSecret
|
||||
- !Ref DbSecretArn
|
||||
- !Ref ValkeyAuthSecretArn
|
||||
|
||||
TaskLogGroup:
|
||||
Type: AWS::Logs::LogGroup
|
||||
Properties:
|
||||
LogGroupName: !Sub "/ecs/${AWS::StackName}"
|
||||
RetentionInDays: 14
|
||||
|
||||
# ----- ALB -----
|
||||
Alb:
|
||||
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
|
||||
Properties:
|
||||
Scheme: internet-facing
|
||||
Subnets: [!Ref SubnetA, !Ref SubnetB]
|
||||
SecurityGroups: [!Ref AlbSg]
|
||||
Type: application
|
||||
|
||||
AppTg:
|
||||
Type: AWS::ElasticLoadBalancingV2::TargetGroup
|
||||
Properties:
|
||||
VpcId: !Ref Vpc
|
||||
Port: 8080
|
||||
Protocol: HTTP
|
||||
TargetType: ip
|
||||
HealthCheckPath: /api/v1/info/status
|
||||
HealthCheckIntervalSeconds: 30
|
||||
HealthCheckTimeoutSeconds: 10
|
||||
HealthyThresholdCount: 2
|
||||
UnhealthyThresholdCount: 5
|
||||
Matcher: {HttpCode: "200"}
|
||||
# Sticky sessions required - see deploy/aws/README.md.
|
||||
TargetGroupAttributes:
|
||||
- Key: stickiness.enabled
|
||||
Value: 'true'
|
||||
- Key: stickiness.type
|
||||
Value: lb_cookie
|
||||
- Key: stickiness.lb_cookie.duration_seconds
|
||||
Value: '86400'
|
||||
|
||||
AlbListener:
|
||||
Type: AWS::ElasticLoadBalancingV2::Listener
|
||||
Properties:
|
||||
LoadBalancerArn: !Ref Alb
|
||||
Port: 80
|
||||
Protocol: HTTP
|
||||
DefaultActions:
|
||||
- Type: forward
|
||||
TargetGroupArn: !Ref AppTg
|
||||
|
||||
AlbBlockInternalRule:
|
||||
Type: AWS::ElasticLoadBalancingV2::ListenerRule
|
||||
Properties:
|
||||
ListenerArn: !Ref AlbListener
|
||||
Priority: 1
|
||||
Conditions:
|
||||
- Field: path-pattern
|
||||
Values: ["/internal/*"]
|
||||
Actions:
|
||||
- Type: fixed-response
|
||||
FixedResponseConfig:
|
||||
StatusCode: "404"
|
||||
ContentType: text/plain
|
||||
MessageBody: "Not Found"
|
||||
|
||||
# ----- ECS task definition for the Stirling app -----
|
||||
AppTaskDef:
|
||||
Type: AWS::ECS::TaskDefinition
|
||||
Properties:
|
||||
Family: !Sub "${AWS::StackName}-app"
|
||||
Cpu: !Ref AppCpu
|
||||
Memory: !Ref AppMemory
|
||||
NetworkMode: awsvpc
|
||||
RequiresCompatibilities: [FARGATE]
|
||||
ExecutionRoleArn: !GetAtt TaskExecutionRole.Arn
|
||||
ContainerDefinitions:
|
||||
- Name: stirling
|
||||
Image: !Ref AppImage
|
||||
Essential: true
|
||||
PortMappings:
|
||||
- ContainerPort: 8080
|
||||
Protocol: tcp
|
||||
LogConfiguration:
|
||||
LogDriver: awslogs
|
||||
Options:
|
||||
awslogs-group: !Ref TaskLogGroup
|
||||
awslogs-region: !Ref AWS::Region
|
||||
awslogs-stream-prefix: app
|
||||
Environment:
|
||||
- {Name: CLUSTER_ENABLED, Value: "true"}
|
||||
- {Name: CLUSTER_BACKPLANE, Value: valkey}
|
||||
# rediss:// = TLS. REDIS_PASSWORD injected separately via Secrets below.
|
||||
- {Name: CLUSTER_VALKEY_URL, Value: !Sub "rediss://${Valkey.PrimaryEndPoint.Address}:6379"}
|
||||
- {Name: SPRING_DATASOURCE_URL, Value: !Sub "jdbc:postgresql://${Postgres.Endpoint.Address}:5432/stirling"}
|
||||
- {Name: SPRING_DATASOURCE_USERNAME, Value: stirling}
|
||||
- {Name: DOCKER_ENABLE_SECURITY, Value: "true"}
|
||||
- {Name: SYSTEM_DEFAULTLOCALE, Value: en-US}
|
||||
- !If
|
||||
- WithAiEngine
|
||||
- {Name: AIENGINE_URL, Value: !Sub "http://${EngineAlb.DNSName}:5001"}
|
||||
- !Ref AWS::NoValue
|
||||
Secrets:
|
||||
- {Name: CLUSTER_ENGINE_SHAREDSECRET, ValueFrom: !Ref EngineSharedSecret}
|
||||
- {Name: SPRING_DATASOURCE_PASSWORD, ValueFrom: !Ref DbSecretArn}
|
||||
- {Name: REDIS_PASSWORD, ValueFrom: !Ref ValkeyAuthSecretArn}
|
||||
|
||||
AppService:
|
||||
Type: AWS::ECS::Service
|
||||
DependsOn: AlbListener
|
||||
Properties:
|
||||
ServiceName: !Sub "${AWS::StackName}-app"
|
||||
Cluster: !Ref EcsCluster
|
||||
DesiredCount: !Ref AppCount
|
||||
LaunchType: FARGATE
|
||||
TaskDefinition: !Ref AppTaskDef
|
||||
# Grace period covers Spring Boot warm-up + Valkey handshake (~60-90s total).
|
||||
HealthCheckGracePeriodSeconds: 120
|
||||
DeploymentConfiguration:
|
||||
MinimumHealthyPercent: 50
|
||||
MaximumPercent: 200
|
||||
NetworkConfiguration:
|
||||
AwsvpcConfiguration:
|
||||
AssignPublicIp: ENABLED
|
||||
Subnets: [!Ref SubnetA, !Ref SubnetB]
|
||||
SecurityGroups: [!Ref AppSg, !Ref InternalSelfSg]
|
||||
LoadBalancers:
|
||||
- ContainerName: stirling
|
||||
ContainerPort: 8080
|
||||
TargetGroupArn: !Ref AppTg
|
||||
|
||||
# ----- optional AI engine service -----
|
||||
EngineAlb:
|
||||
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
|
||||
Condition: WithAiEngine
|
||||
Properties:
|
||||
Scheme: internal
|
||||
Subnets: [!Ref SubnetA, !Ref SubnetB]
|
||||
SecurityGroups: [!Ref EngineLbSg]
|
||||
Type: application
|
||||
|
||||
EngineTg:
|
||||
Type: AWS::ElasticLoadBalancingV2::TargetGroup
|
||||
Condition: WithAiEngine
|
||||
Properties:
|
||||
VpcId: !Ref Vpc
|
||||
Port: 5001
|
||||
Protocol: HTTP
|
||||
TargetType: ip
|
||||
HealthCheckPath: /health
|
||||
HealthCheckIntervalSeconds: 30
|
||||
HealthCheckTimeoutSeconds: 10
|
||||
HealthyThresholdCount: 2
|
||||
UnhealthyThresholdCount: 5
|
||||
Matcher: {HttpCode: "200"}
|
||||
|
||||
EngineAlbListener:
|
||||
Type: AWS::ElasticLoadBalancingV2::Listener
|
||||
Condition: WithAiEngine
|
||||
Properties:
|
||||
LoadBalancerArn: !Ref EngineAlb
|
||||
Port: 5001
|
||||
Protocol: HTTP
|
||||
DefaultActions:
|
||||
- Type: forward
|
||||
TargetGroupArn: !Ref EngineTg
|
||||
|
||||
EngineTaskDef:
|
||||
Type: AWS::ECS::TaskDefinition
|
||||
Condition: WithAiEngine
|
||||
Properties:
|
||||
Family: !Sub "${AWS::StackName}-engine"
|
||||
Cpu: 1024
|
||||
Memory: 2048
|
||||
NetworkMode: awsvpc
|
||||
RequiresCompatibilities: [FARGATE]
|
||||
ExecutionRoleArn: !GetAtt TaskExecutionRole.Arn
|
||||
ContainerDefinitions:
|
||||
- Name: engine
|
||||
Image: !Ref EngineImage
|
||||
PortMappings: [{ContainerPort: 5001, Protocol: tcp}]
|
||||
LogConfiguration:
|
||||
LogDriver: awslogs
|
||||
Options:
|
||||
awslogs-group: !Ref TaskLogGroup
|
||||
awslogs-region: !Ref AWS::Region
|
||||
awslogs-stream-prefix: engine
|
||||
Secrets:
|
||||
- {Name: STIRLING_ENGINE_SHARED_SECRET, ValueFrom: !Ref EngineSharedSecret}
|
||||
|
||||
EngineService:
|
||||
Type: AWS::ECS::Service
|
||||
Condition: WithAiEngine
|
||||
DependsOn: EngineAlbListener
|
||||
Properties:
|
||||
ServiceName: !Sub "${AWS::StackName}-engine"
|
||||
Cluster: !Ref EcsCluster
|
||||
DesiredCount: 1
|
||||
LaunchType: FARGATE
|
||||
TaskDefinition: !Ref EngineTaskDef
|
||||
# Engine model-load warm-up can take 60-90s.
|
||||
HealthCheckGracePeriodSeconds: 120
|
||||
NetworkConfiguration:
|
||||
AwsvpcConfiguration:
|
||||
AssignPublicIp: ENABLED
|
||||
Subnets: [!Ref SubnetA, !Ref SubnetB]
|
||||
SecurityGroups: [!Ref EngineSg]
|
||||
LoadBalancers:
|
||||
- ContainerName: engine
|
||||
ContainerPort: 5001
|
||||
TargetGroupArn: !Ref EngineTg
|
||||
|
||||
# ----- HPA-equivalent: target tracking on CPU -----
|
||||
AppScalingTarget:
|
||||
Type: AWS::ApplicationAutoScaling::ScalableTarget
|
||||
Properties:
|
||||
MaxCapacity: 10
|
||||
MinCapacity: !Ref AppCount
|
||||
ResourceId: !Sub "service/${EcsCluster}/${AppService.Name}"
|
||||
ScalableDimension: ecs:service:DesiredCount
|
||||
ServiceNamespace: ecs
|
||||
RoleARN: !Sub "arn:aws:iam::${AWS::AccountId}:role/aws-service-role/ecs.application-autoscaling.amazonaws.com/AWSServiceRoleForApplicationAutoScaling_ECSService"
|
||||
|
||||
AppScalingPolicy:
|
||||
Type: AWS::ApplicationAutoScaling::ScalingPolicy
|
||||
Properties:
|
||||
PolicyName: !Sub "${AWS::StackName}-cpu-target"
|
||||
PolicyType: TargetTrackingScaling
|
||||
ScalingTargetId: !Ref AppScalingTarget
|
||||
TargetTrackingScalingPolicyConfiguration:
|
||||
TargetValue: 70
|
||||
PredefinedMetricSpecification:
|
||||
PredefinedMetricType: ECSServiceAverageCPUUtilization
|
||||
ScaleInCooldown: 60
|
||||
ScaleOutCooldown: 60
|
||||
|
||||
Outputs:
|
||||
AppUrl:
|
||||
Description: Open this URL - Stirling-PDF cluster front door
|
||||
Value: !Sub "http://${Alb.DNSName}/"
|
||||
ClusterName:
|
||||
Value: !Ref EcsCluster
|
||||
ValkeyEndpoint:
|
||||
Value: !GetAtt Valkey.PrimaryEndPoint.Address
|
||||
PostgresEndpoint:
|
||||
Value: !GetAtt Postgres.Endpoint.Address
|
||||
StackTeardown:
|
||||
Description: To delete everything (RDS keeps a final snapshot)
|
||||
Value: !Sub "aws cloudformation delete-stack --stack-name ${AWS::StackName}"
|
||||
@@ -1,108 +0,0 @@
|
||||
# Single-EC2 Quickstart - "just give me Docker on a VM"
|
||||
|
||||
The fastest possible AWS deployment when you don't want managed services. **Good
|
||||
for ≤25 concurrent users on a single beefy VM**. Past that, jump to the
|
||||
CloudFormation or Terraform option which scales horizontally.
|
||||
|
||||
## What you get
|
||||
|
||||
- One EC2 instance running 2 Stirling app containers, 1 Valkey, 1 Postgres,
|
||||
1 nginx LB - exactly the validated `validation/compose.test.yml` topology
|
||||
- ~$25-40/month for a `t3.large`
|
||||
- 5-minute deploy
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Launch an EC2 instance
|
||||
|
||||
- AMI: Amazon Linux 2023 (or Ubuntu 22.04 LTS)
|
||||
- Instance type: `t3.large` (2 vCPU, 8 GB RAM) minimum
|
||||
- Storage: 30 GB gp3
|
||||
- Security group: open `80/tcp` (and `22/tcp` for SSH)
|
||||
- IAM role: none needed
|
||||
|
||||
### 2. SSH in and install Docker
|
||||
|
||||
```bash
|
||||
sudo dnf install -y docker git
|
||||
sudo systemctl enable --now docker
|
||||
sudo usermod -aG docker ec2-user
|
||||
# new shell so the group takes effect
|
||||
exit
|
||||
```
|
||||
|
||||
(On Ubuntu: `sudo apt install -y docker.io docker-compose-plugin git`.)
|
||||
|
||||
### 3. Pull the compose stack
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Stirling-Tools/Stirling-PDF.git
|
||||
cd Stirling-PDF
|
||||
git checkout v2.11.0
|
||||
```
|
||||
|
||||
### 4. Set secrets and start
|
||||
|
||||
```bash
|
||||
export CLUSTER_ENGINE_SHAREDSECRET=$(openssl rand -hex 16)
|
||||
export STIRLING_VALKEY_PASSWORD=$(openssl rand -hex 16)
|
||||
export POSTGRES_PASSWORD=$(openssl rand -hex 16)
|
||||
docker compose -f docker/compose/docker-compose-cluster.yml up -d --build
|
||||
```
|
||||
|
||||
Wait ~2 min for the apps to come up:
|
||||
|
||||
```bash
|
||||
until curl -fsS http://localhost:8080/api/v1/info/status | grep -q UP; do sleep 5; done
|
||||
echo OK
|
||||
```
|
||||
|
||||
### 5. Open it
|
||||
|
||||
`http://<EC2 public IP>/`
|
||||
|
||||
### 6. (Optional) HTTPS
|
||||
|
||||
Slap Caddy or Traefik in front, or use AWS ALB pointing at the EC2 instance.
|
||||
Easiest is Caddy:
|
||||
|
||||
```bash
|
||||
docker run -d --name caddy --restart unless-stopped \
|
||||
-p 80:80 -p 443:443 \
|
||||
-v caddy_data:/data \
|
||||
-v $PWD/Caddyfile:/etc/caddy/Caddyfile \
|
||||
caddy
|
||||
```
|
||||
|
||||
with a tiny `Caddyfile`:
|
||||
|
||||
```
|
||||
stirling.yourdomain.com {
|
||||
reverse_proxy host.docker.internal:8080
|
||||
}
|
||||
```
|
||||
|
||||
Caddy fetches a Let's Encrypt cert automatically.
|
||||
|
||||
## Backups
|
||||
|
||||
- **Postgres**: `docker exec stirling-postgres pg_dump -U stirling stirling > backup-$(date +%F).sql`
|
||||
Cron this and ship to S3 with `aws s3 cp`.
|
||||
- **Valkey**: state is short-TTL (job status, rate-limit counters); no backup
|
||||
needed - losing it on restart just means in-flight async jobs need re-running.
|
||||
|
||||
## Tear-down
|
||||
|
||||
`docker compose -f docker/compose/docker-compose-cluster.yml down -v` then
|
||||
terminate the EC2 instance.
|
||||
|
||||
## Limits of this path
|
||||
|
||||
- Single point of failure (one VM)
|
||||
- Manual scaling: edit `app-3`/`app-4` services into the compose file, restart
|
||||
- No autoscaling
|
||||
- No managed-service backups for Valkey
|
||||
- nginx LB runs on the same VM as the apps
|
||||
|
||||
If any of these matter, use the CloudFormation or Terraform option in this
|
||||
directory instead.
|
||||
Generated
-25
@@ -1,25 +0,0 @@
|
||||
# This file is maintained automatically by "terraform init".
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.terraform.io/hashicorp/aws" {
|
||||
version = "5.100.0"
|
||||
constraints = "~> 5.0"
|
||||
hashes = [
|
||||
"h1:H3mU/7URhP0uCRGK8jeQRKxx2XFzEqLiOq/L2Bbiaxs=",
|
||||
"zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644",
|
||||
"zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2",
|
||||
"zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274",
|
||||
"zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b",
|
||||
"zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862",
|
||||
"zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342",
|
||||
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
|
||||
"zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93",
|
||||
"zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2",
|
||||
"zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e",
|
||||
"zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421",
|
||||
"zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4",
|
||||
"zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9",
|
||||
"zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9",
|
||||
"zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70",
|
||||
]
|
||||
}
|
||||
-375
@@ -1,375 +0,0 @@
|
||||
Copyright (c) 2017 HashiCorp, Inc.
|
||||
|
||||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
|
||||
1. Definitions
|
||||
--------------
|
||||
|
||||
1.1. "Contributor"
|
||||
means each individual or legal entity that creates, contributes to
|
||||
the creation of, or owns Covered Software.
|
||||
|
||||
1.2. "Contributor Version"
|
||||
means the combination of the Contributions of others (if any) used
|
||||
by a Contributor and that particular Contributor's Contribution.
|
||||
|
||||
1.3. "Contribution"
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
1.4. "Covered Software"
|
||||
means Source Code Form to which the initial Contributor has attached
|
||||
the notice in Exhibit A, the Executable Form of such Source Code
|
||||
Form, and Modifications of such Source Code Form, in each case
|
||||
including portions thereof.
|
||||
|
||||
1.5. "Incompatible With Secondary Licenses"
|
||||
means
|
||||
|
||||
(a) that the initial Contributor has attached the notice described
|
||||
in Exhibit B to the Covered Software; or
|
||||
|
||||
(b) that the Covered Software was made available under the terms of
|
||||
version 1.1 or earlier of the License, but not also under the
|
||||
terms of a Secondary License.
|
||||
|
||||
1.6. "Executable Form"
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
1.7. "Larger Work"
|
||||
means a work that combines Covered Software with other material, in
|
||||
a separate file or files, that is not Covered Software.
|
||||
|
||||
1.8. "License"
|
||||
means this document.
|
||||
|
||||
1.9. "Licensable"
|
||||
means having the right to grant, to the maximum extent possible,
|
||||
whether at the time of the initial grant or subsequently, any and
|
||||
all of the rights conveyed by this License.
|
||||
|
||||
1.10. "Modifications"
|
||||
means any of the following:
|
||||
|
||||
(a) any file in Source Code Form that results from an addition to,
|
||||
deletion from, or modification of the contents of Covered
|
||||
Software; or
|
||||
|
||||
(b) any new file in Source Code Form that contains any Covered
|
||||
Software.
|
||||
|
||||
1.11. "Patent Claims" of a Contributor
|
||||
means any patent claim(s), including without limitation, method,
|
||||
process, and apparatus claims, in any patent Licensable by such
|
||||
Contributor that would be infringed, but for the grant of the
|
||||
License, by the making, using, selling, offering for sale, having
|
||||
made, import, or transfer of either its Contributions or its
|
||||
Contributor Version.
|
||||
|
||||
1.12. "Secondary License"
|
||||
means either the GNU General Public License, Version 2.0, the GNU
|
||||
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||
Public License, Version 3.0, or any later versions of those
|
||||
licenses.
|
||||
|
||||
1.13. "Source Code Form"
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
1.14. "You" (or "Your")
|
||||
means an individual or a legal entity exercising rights under this
|
||||
License. For legal entities, "You" includes any entity that
|
||||
controls, is controlled by, or is under common control with You. For
|
||||
purposes of this definition, "control" means (a) the power, direct
|
||||
or indirect, to cause the direction or management of such entity,
|
||||
whether by contract or otherwise, or (b) ownership of more than
|
||||
fifty percent (50%) of the outstanding shares or beneficial
|
||||
ownership of such entity.
|
||||
|
||||
2. License Grants and Conditions
|
||||
--------------------------------
|
||||
|
||||
2.1. Grants
|
||||
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
(a) under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications, or
|
||||
as part of a Larger Work; and
|
||||
|
||||
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||
for sale, have made, import, and otherwise transfer either its
|
||||
Contributions or its Contributor Version.
|
||||
|
||||
2.2. Effective Date
|
||||
|
||||
The licenses granted in Section 2.1 with respect to any Contribution
|
||||
become effective for each Contribution on the date the Contributor first
|
||||
distributes such Contribution.
|
||||
|
||||
2.3. Limitations on Grant Scope
|
||||
|
||||
The licenses granted in this Section 2 are the only rights granted under
|
||||
this License. No additional rights or licenses will be implied from the
|
||||
distribution or licensing of Covered Software under this License.
|
||||
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||
Contributor:
|
||||
|
||||
(a) for any code that a Contributor has removed from Covered Software;
|
||||
or
|
||||
|
||||
(b) for infringements caused by: (i) Your and any other third party's
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its Contributor
|
||||
Version); or
|
||||
|
||||
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||
its Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks,
|
||||
or logos of any Contributor (except as may be necessary to comply with
|
||||
the notice requirements in Section 3.4).
|
||||
|
||||
2.4. Subsequent Licenses
|
||||
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this
|
||||
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||
permitted under the terms of Section 3.3).
|
||||
|
||||
2.5. Representation
|
||||
|
||||
Each Contributor represents that the Contributor believes its
|
||||
Contributions are its original creation(s) or it has sufficient rights
|
||||
to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
2.6. Fair Use
|
||||
|
||||
This License is not intended to limit any rights You have under
|
||||
applicable copyright doctrines of fair use, fair dealing, or other
|
||||
equivalents.
|
||||
|
||||
2.7. Conditions
|
||||
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||
in Section 2.1.
|
||||
|
||||
3. Responsibilities
|
||||
-------------------
|
||||
|
||||
3.1. Distribution of Source Form
|
||||
|
||||
All distribution of Covered Software in Source Code Form, including any
|
||||
Modifications that You create or to which You contribute, must be under
|
||||
the terms of this License. You must inform recipients that the Source
|
||||
Code Form of the Covered Software is governed by the terms of this
|
||||
License, and how they can obtain a copy of this License. You may not
|
||||
attempt to alter or restrict the recipients' rights in the Source Code
|
||||
Form.
|
||||
|
||||
3.2. Distribution of Executable Form
|
||||
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
(a) such Covered Software must also be made available in Source Code
|
||||
Form, as described in Section 3.1, and You must inform recipients of
|
||||
the Executable Form how they can obtain a copy of such Source Code
|
||||
Form by reasonable means in a timely manner, at a charge no more
|
||||
than the cost of distribution to the recipient; and
|
||||
|
||||
(b) You may distribute such Executable Form under the terms of this
|
||||
License, or sublicense it under different terms, provided that the
|
||||
license for the Executable Form does not attempt to limit or alter
|
||||
the recipients' rights in the Source Code Form under this License.
|
||||
|
||||
3.3. Distribution of a Larger Work
|
||||
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for
|
||||
the Covered Software. If the Larger Work is a combination of Covered
|
||||
Software with a work governed by one or more Secondary Licenses, and the
|
||||
Covered Software is not Incompatible With Secondary Licenses, this
|
||||
License permits You to additionally distribute such Covered Software
|
||||
under the terms of such Secondary License(s), so that the recipient of
|
||||
the Larger Work may, at their option, further distribute the Covered
|
||||
Software under the terms of either this License or such Secondary
|
||||
License(s).
|
||||
|
||||
3.4. Notices
|
||||
|
||||
You may not remove or alter the substance of any license notices
|
||||
(including copyright notices, patent notices, disclaimers of warranty,
|
||||
or limitations of liability) contained within the Source Code Form of
|
||||
the Covered Software, except that You may alter any license notices to
|
||||
the extent required to remedy known factual inaccuracies.
|
||||
|
||||
3.5. Application of Additional Terms
|
||||
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of Covered
|
||||
Software. However, You may do so only on Your own behalf, and not on
|
||||
behalf of any Contributor. You must make it absolutely clear that any
|
||||
such warranty, support, indemnity, or liability obligation is offered by
|
||||
You alone, and You hereby agree to indemnify every Contributor for any
|
||||
liability incurred by such Contributor as a result of warranty, support,
|
||||
indemnity or liability terms You offer. You may include additional
|
||||
disclaimers of warranty and limitations of liability specific to any
|
||||
jurisdiction.
|
||||
|
||||
4. Inability to Comply Due to Statute or Regulation
|
||||
---------------------------------------------------
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this
|
||||
License with respect to some or all of the Covered Software due to
|
||||
statute, judicial order, or regulation then You must: (a) comply with
|
||||
the terms of this License to the maximum extent possible; and (b)
|
||||
describe the limitations and the code they affect. Such description must
|
||||
be placed in a text file included with all distributions of the Covered
|
||||
Software under this License. Except to the extent prohibited by statute
|
||||
or regulation, such description must be sufficiently detailed for a
|
||||
recipient of ordinary skill to be able to understand it.
|
||||
|
||||
5. Termination
|
||||
--------------
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically
|
||||
if You fail to comply with any of its terms. However, if You become
|
||||
compliant, then the rights granted under this License from a particular
|
||||
Contributor are reinstated (a) provisionally, unless and until such
|
||||
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||
ongoing basis, if such Contributor fails to notify You of the
|
||||
non-compliance by some reasonable means prior to 60 days after You have
|
||||
come back into compliance. Moreover, Your grants from a particular
|
||||
Contributor are reinstated on an ongoing basis if such Contributor
|
||||
notifies You of the non-compliance by some reasonable means, this is the
|
||||
first time You have received notice of non-compliance with this License
|
||||
from such Contributor, and You become compliant prior to 30 days after
|
||||
Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions,
|
||||
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||
directly or indirectly infringes any patent, then the rights granted to
|
||||
You by any and all Contributors for the Covered Software under Section
|
||||
2.1 of this License shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||
end user license agreements (excluding distributors and resellers) which
|
||||
have been validly granted by You or Your distributors under this License
|
||||
prior to termination shall survive termination.
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 6. Disclaimer of Warranty *
|
||||
* ------------------------- *
|
||||
* *
|
||||
* Covered Software is provided under this License on an "as is" *
|
||||
* basis, without warranty of any kind, either expressed, implied, or *
|
||||
* statutory, including, without limitation, warranties that the *
|
||||
* Covered Software is free of defects, merchantable, fit for a *
|
||||
* particular purpose or non-infringing. The entire risk as to the *
|
||||
* quality and performance of the Covered Software is with You. *
|
||||
* Should any Covered Software prove defective in any respect, You *
|
||||
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||
* essential part of this License. No use of any Covered Software is *
|
||||
* authorized under this License except under this disclaimer. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 7. Limitation of Liability *
|
||||
* -------------------------- *
|
||||
* *
|
||||
* Under no circumstances and under no legal theory, whether tort *
|
||||
* (including negligence), contract, or otherwise, shall any *
|
||||
* Contributor, or anyone who distributes Covered Software as *
|
||||
* permitted above, be liable to You for any direct, indirect, *
|
||||
* special, incidental, or consequential damages of any character *
|
||||
* including, without limitation, damages for lost profits, loss of *
|
||||
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||
* and all other commercial damages or losses, even if such party *
|
||||
* shall have been informed of the possibility of such damages. This *
|
||||
* limitation of liability shall not apply to liability for death or *
|
||||
* personal injury resulting from such party's negligence to the *
|
||||
* extent applicable law prohibits such limitation. Some *
|
||||
* jurisdictions do not allow the exclusion or limitation of *
|
||||
* incidental or consequential damages, so this exclusion and *
|
||||
* limitation may not apply to You. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
8. Litigation
|
||||
-------------
|
||||
|
||||
Any litigation relating to this License may be brought only in the
|
||||
courts of a jurisdiction where the defendant maintains its principal
|
||||
place of business and such litigation shall be governed by laws of that
|
||||
jurisdiction, without reference to its conflict-of-law provisions.
|
||||
Nothing in this Section shall prevent a party's ability to bring
|
||||
cross-claims or counter-claims.
|
||||
|
||||
9. Miscellaneous
|
||||
----------------
|
||||
|
||||
This License represents the complete agreement concerning the subject
|
||||
matter hereof. If any provision of this License is held to be
|
||||
unenforceable, such provision shall be reformed only to the extent
|
||||
necessary to make it enforceable. Any law or regulation which provides
|
||||
that the language of a contract shall be construed against the drafter
|
||||
shall not be used to construe this License against a Contributor.
|
||||
|
||||
10. Versions of the License
|
||||
---------------------------
|
||||
|
||||
10.1. New Versions
|
||||
|
||||
Mozilla Foundation is the license steward. Except as provided in Section
|
||||
10.3, no one other than the license steward has the right to modify or
|
||||
publish new versions of this License. Each version will be given a
|
||||
distinguishing version number.
|
||||
|
||||
10.2. Effect of New Versions
|
||||
|
||||
You may distribute the Covered Software under the terms of the version
|
||||
of the License under which You originally received the Covered Software,
|
||||
or under the terms of any subsequent version published by the license
|
||||
steward.
|
||||
|
||||
10.3. Modified Versions
|
||||
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a
|
||||
modified version of this License if you rename the license and remove
|
||||
any references to the name of the license steward (except to note that
|
||||
such modified license differs from this License).
|
||||
|
||||
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||
Licenses
|
||||
|
||||
If You choose to distribute Source Code Form that is Incompatible With
|
||||
Secondary Licenses under the terms of this version of the License, the
|
||||
notice described in Exhibit B of this License must be attached.
|
||||
|
||||
Exhibit A - Source Code Form License Notice
|
||||
-------------------------------------------
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular
|
||||
file, then You may include the notice in a location (such as a LICENSE
|
||||
file in a relevant directory) where a recipient would be likely to look
|
||||
for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
---------------------------------------------------------
|
||||
|
||||
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
defined by the Mozilla Public License, v. 2.0.
|
||||
@@ -1,642 +0,0 @@
|
||||
# Stirling-PDF on AWS - Terraform module (single-module starting point).
|
||||
# Production users typically split into modules/{vpc,ecs,rds,elasticache,alb}; this shape
|
||||
# is meant as a copy-and-fill-in template.
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.6"
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = "~> 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
}
|
||||
|
||||
# ----- inputs -----
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
}
|
||||
|
||||
variable "name" {
|
||||
type = string
|
||||
default = "stirling"
|
||||
}
|
||||
|
||||
variable "app_image" {
|
||||
type = string
|
||||
# Never use :latest in production - breaks reproducible deploys and rollback.
|
||||
default = "stirlingtools/stirling-pdf:2.11.0"
|
||||
}
|
||||
|
||||
variable "engine_image" {
|
||||
type = string
|
||||
default = "stirlingtools/stirling-pdf-ai-engine:2.11.0"
|
||||
}
|
||||
|
||||
variable "app_count" {
|
||||
type = number
|
||||
default = 2
|
||||
}
|
||||
|
||||
variable "app_cpu" {
|
||||
type = number
|
||||
default = 1024
|
||||
}
|
||||
|
||||
variable "app_memory" {
|
||||
type = number
|
||||
default = 4096
|
||||
}
|
||||
|
||||
variable "engine_count" {
|
||||
type = number
|
||||
default = 1
|
||||
}
|
||||
|
||||
variable "enable_ai_engine" {
|
||||
type = bool
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "db_password" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "engine_shared_secret" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "valkey_auth_token" {
|
||||
# ElastiCache AUTH token (16-128 chars). Generate: openssl rand -hex 32
|
||||
type = string
|
||||
sensitive = true
|
||||
validation {
|
||||
condition = length(var.valkey_auth_token) >= 16 && length(var.valkey_auth_token) <= 128
|
||||
error_message = "valkey_auth_token must be between 16 and 128 characters."
|
||||
}
|
||||
}
|
||||
|
||||
variable "vpc_cidr" {
|
||||
type = string
|
||||
default = "10.42.0.0/16"
|
||||
}
|
||||
|
||||
# ----- networking (slim VPC, two AZs) -----
|
||||
resource "aws_vpc" "main" {
|
||||
cidr_block = var.vpc_cidr
|
||||
enable_dns_hostnames = true
|
||||
enable_dns_support = true
|
||||
tags = {
|
||||
Name = "${var.name}-vpc"
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_availability_zones" "available" {
|
||||
state = "available"
|
||||
}
|
||||
|
||||
resource "aws_subnet" "a" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
cidr_block = cidrsubnet(var.vpc_cidr, 8, 1)
|
||||
availability_zone = data.aws_availability_zones.available.names[0]
|
||||
map_public_ip_on_launch = true
|
||||
}
|
||||
|
||||
resource "aws_subnet" "b" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
cidr_block = cidrsubnet(var.vpc_cidr, 8, 2)
|
||||
availability_zone = data.aws_availability_zones.available.names[1]
|
||||
map_public_ip_on_launch = true
|
||||
}
|
||||
|
||||
resource "aws_internet_gateway" "igw" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
}
|
||||
|
||||
resource "aws_route_table" "public" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
route {
|
||||
cidr_block = "0.0.0.0/0"
|
||||
gateway_id = aws_internet_gateway.igw.id
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_route_table_association" "a" {
|
||||
subnet_id = aws_subnet.a.id
|
||||
route_table_id = aws_route_table.public.id
|
||||
}
|
||||
|
||||
resource "aws_route_table_association" "b" {
|
||||
subnet_id = aws_subnet.b.id
|
||||
route_table_id = aws_route_table.public.id
|
||||
}
|
||||
|
||||
# ----- security groups -----
|
||||
resource "aws_security_group" "alb" {
|
||||
name = "${var.name}-alb"
|
||||
description = "Public ALB"
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
ingress {
|
||||
from_port = 80
|
||||
to_port = 80
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
ingress {
|
||||
from_port = 443
|
||||
to_port = 443
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
egress {
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_security_group" "app" {
|
||||
name = "${var.name}-app"
|
||||
description = "Stirling app tasks"
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
egress {
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_security_group_rule" "alb_to_app" {
|
||||
type = "ingress"
|
||||
from_port = 8080
|
||||
to_port = 8080
|
||||
protocol = "tcp"
|
||||
security_group_id = aws_security_group.app.id
|
||||
source_security_group_id = aws_security_group.alb.id
|
||||
}
|
||||
|
||||
resource "aws_security_group_rule" "app_to_app" {
|
||||
type = "ingress"
|
||||
from_port = 8080
|
||||
to_port = 8080
|
||||
protocol = "tcp"
|
||||
security_group_id = aws_security_group.app.id
|
||||
source_security_group_id = aws_security_group.app.id
|
||||
}
|
||||
|
||||
resource "aws_security_group" "valkey" {
|
||||
name = "${var.name}-valkey"
|
||||
description = "Valkey ElastiCache"
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
ingress {
|
||||
from_port = 6379
|
||||
to_port = 6379
|
||||
protocol = "tcp"
|
||||
security_groups = [aws_security_group.app.id]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_security_group" "db" {
|
||||
name = "${var.name}-db"
|
||||
description = "PostgreSQL"
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
ingress {
|
||||
from_port = 5432
|
||||
to_port = 5432
|
||||
protocol = "tcp"
|
||||
security_groups = [aws_security_group.app.id]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_security_group" "engine_lb" {
|
||||
count = var.enable_ai_engine ? 1 : 0
|
||||
name = "${var.name}-engine-lb"
|
||||
description = "Internal ALB in front of engine tier - app tasks only"
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
ingress {
|
||||
from_port = 5001
|
||||
to_port = 5001
|
||||
protocol = "tcp"
|
||||
security_groups = [aws_security_group.app.id]
|
||||
}
|
||||
|
||||
egress {
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_security_group" "engine" {
|
||||
count = var.enable_ai_engine ? 1 : 0
|
||||
name = "${var.name}-engine"
|
||||
description = "AI engine tasks - only reachable from the internal engine LB"
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
ingress {
|
||||
from_port = 5001
|
||||
to_port = 5001
|
||||
protocol = "tcp"
|
||||
security_groups = [aws_security_group.engine_lb[0].id]
|
||||
}
|
||||
|
||||
egress {
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
}
|
||||
|
||||
# ----- managed Valkey (ElastiCache for Valkey, GA since 2024) -----
|
||||
resource "aws_elasticache_subnet_group" "valkey" {
|
||||
name = "${var.name}-valkey"
|
||||
subnet_ids = [aws_subnet.a.id, aws_subnet.b.id]
|
||||
}
|
||||
|
||||
resource "aws_elasticache_replication_group" "valkey" {
|
||||
replication_group_id = "${var.name}-valkey"
|
||||
description = "Stirling Valkey"
|
||||
engine = "valkey"
|
||||
engine_version = "8.0"
|
||||
node_type = "cache.t4g.small"
|
||||
num_cache_clusters = 1
|
||||
automatic_failover_enabled = false
|
||||
subnet_group_name = aws_elasticache_subnet_group.valkey.name
|
||||
security_group_ids = [aws_security_group.valkey.id]
|
||||
at_rest_encryption_enabled = true
|
||||
# TLS in flight required when auth_token is set (ElastiCache enforces this).
|
||||
transit_encryption_enabled = true
|
||||
auth_token = var.valkey_auth_token
|
||||
}
|
||||
|
||||
# ----- managed Postgres -----
|
||||
resource "aws_db_subnet_group" "pg" {
|
||||
name = "${var.name}-pg"
|
||||
subnet_ids = [aws_subnet.a.id, aws_subnet.b.id]
|
||||
}
|
||||
|
||||
resource "aws_db_instance" "pg" {
|
||||
identifier = "${var.name}-pg"
|
||||
engine = "postgres"
|
||||
engine_version = "17.2"
|
||||
instance_class = "db.t4g.micro"
|
||||
allocated_storage = 20
|
||||
username = "stirling"
|
||||
password = var.db_password
|
||||
db_name = "stirling"
|
||||
db_subnet_group_name = aws_db_subnet_group.pg.name
|
||||
vpc_security_group_ids = [aws_security_group.db.id]
|
||||
storage_encrypted = true
|
||||
skip_final_snapshot = false
|
||||
final_snapshot_identifier = "${var.name}-pg-final"
|
||||
backup_retention_period = 7
|
||||
}
|
||||
|
||||
# ----- secrets -----
|
||||
resource "aws_secretsmanager_secret" "bundle" {
|
||||
name = "${var.name}-secrets"
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "bundle" {
|
||||
secret_id = aws_secretsmanager_secret.bundle.id
|
||||
secret_string = jsonencode({
|
||||
engineSharedSecret = var.engine_shared_secret
|
||||
dbPassword = var.db_password
|
||||
valkeyAuthToken = var.valkey_auth_token
|
||||
})
|
||||
}
|
||||
|
||||
# ----- ECS cluster + IAM -----
|
||||
resource "aws_ecs_cluster" "main" {
|
||||
name = "${var.name}-cluster"
|
||||
setting {
|
||||
name = "containerInsights"
|
||||
value = "enabled"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "exec" {
|
||||
name = "${var.name}-exec"
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Principal = { Service = "ecs-tasks.amazonaws.com" }
|
||||
Action = "sts:AssumeRole"
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "exec_managed" {
|
||||
role = aws_iam_role.exec.name
|
||||
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "exec_read_secrets" {
|
||||
role = aws_iam_role.exec.id
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = ["secretsmanager:GetSecretValue"]
|
||||
Resource = aws_secretsmanager_secret.bundle.arn
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_cloudwatch_log_group" "ecs" {
|
||||
name = "/ecs/${var.name}"
|
||||
retention_in_days = 14
|
||||
}
|
||||
|
||||
# ----- ALB + target group + listener (blocks /internal/*) -----
|
||||
resource "aws_lb" "alb" {
|
||||
name = "${var.name}-alb"
|
||||
load_balancer_type = "application"
|
||||
subnets = [aws_subnet.a.id, aws_subnet.b.id]
|
||||
security_groups = [aws_security_group.alb.id]
|
||||
}
|
||||
|
||||
resource "aws_lb_target_group" "app" {
|
||||
name = "${var.name}-app-tg"
|
||||
vpc_id = aws_vpc.main.id
|
||||
port = 8080
|
||||
protocol = "HTTP"
|
||||
target_type = "ip"
|
||||
|
||||
health_check {
|
||||
path = "/api/v1/info/status"
|
||||
matcher = "200"
|
||||
interval = 30
|
||||
timeout = 10
|
||||
healthy_threshold = 2
|
||||
unhealthy_threshold = 5
|
||||
}
|
||||
|
||||
# Sticky sessions required - see deploy/aws/README.md.
|
||||
stickiness {
|
||||
type = "lb_cookie"
|
||||
cookie_duration = 86400
|
||||
enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_lb_listener" "http" {
|
||||
load_balancer_arn = aws_lb.alb.arn
|
||||
port = 80
|
||||
protocol = "HTTP"
|
||||
|
||||
default_action {
|
||||
type = "forward"
|
||||
target_group_arn = aws_lb_target_group.app.arn
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_lb_listener_rule" "block_internal" {
|
||||
listener_arn = aws_lb_listener.http.arn
|
||||
priority = 1
|
||||
|
||||
condition {
|
||||
path_pattern {
|
||||
values = ["/internal/*"]
|
||||
}
|
||||
}
|
||||
|
||||
action {
|
||||
type = "fixed-response"
|
||||
fixed_response {
|
||||
status_code = "404"
|
||||
content_type = "text/plain"
|
||||
message_body = "Not Found"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ----- app task definition + service -----
|
||||
resource "aws_ecs_task_definition" "app" {
|
||||
family = "${var.name}-app"
|
||||
cpu = var.app_cpu
|
||||
memory = var.app_memory
|
||||
network_mode = "awsvpc"
|
||||
requires_compatibilities = ["FARGATE"]
|
||||
execution_role_arn = aws_iam_role.exec.arn
|
||||
|
||||
container_definitions = jsonencode([{
|
||||
name = "stirling"
|
||||
image = var.app_image
|
||||
essential = true
|
||||
portMappings = [{
|
||||
containerPort = 8080
|
||||
protocol = "tcp"
|
||||
}]
|
||||
logConfiguration = {
|
||||
logDriver = "awslogs"
|
||||
options = {
|
||||
awslogs-group = aws_cloudwatch_log_group.ecs.name
|
||||
awslogs-region = var.region
|
||||
awslogs-stream-prefix = "app"
|
||||
}
|
||||
}
|
||||
environment = concat([
|
||||
{ name = "CLUSTER_ENABLED", value = "true" },
|
||||
{ name = "CLUSTER_BACKPLANE", value = "valkey" },
|
||||
# rediss:// = TLS. REDIS_PASSWORD injected separately via secrets below.
|
||||
{ name = "CLUSTER_VALKEY_URL", value = "rediss://${aws_elasticache_replication_group.valkey.primary_endpoint_address}:6379" },
|
||||
{ name = "SPRING_DATASOURCE_URL", value = "jdbc:postgresql://${aws_db_instance.pg.endpoint}/stirling" },
|
||||
{ name = "SPRING_DATASOURCE_USERNAME", value = "stirling" },
|
||||
{ name = "DOCKER_ENABLE_SECURITY", value = "true" },
|
||||
],
|
||||
var.enable_ai_engine ? [
|
||||
{ name = "AIENGINE_URL", value = "http://${aws_lb.engine[0].dns_name}:5001" },
|
||||
] : []
|
||||
)
|
||||
secrets = [
|
||||
{ name = "CLUSTER_ENGINE_SHAREDSECRET", valueFrom = "${aws_secretsmanager_secret.bundle.arn}:engineSharedSecret::" },
|
||||
{ name = "SPRING_DATASOURCE_PASSWORD", valueFrom = "${aws_secretsmanager_secret.bundle.arn}:dbPassword::" },
|
||||
{ name = "REDIS_PASSWORD", valueFrom = "${aws_secretsmanager_secret.bundle.arn}:valkeyAuthToken::" },
|
||||
]
|
||||
}])
|
||||
}
|
||||
|
||||
resource "aws_ecs_service" "app" {
|
||||
name = "${var.name}-app"
|
||||
cluster = aws_ecs_cluster.main.id
|
||||
task_definition = aws_ecs_task_definition.app.arn
|
||||
desired_count = var.app_count
|
||||
launch_type = "FARGATE"
|
||||
deployment_minimum_healthy_percent = 50
|
||||
deployment_maximum_percent = 200
|
||||
# Grace period covers Spring Boot warm-up + Valkey handshake (~60-90s total).
|
||||
health_check_grace_period_seconds = 120
|
||||
|
||||
network_configuration {
|
||||
subnets = [aws_subnet.a.id, aws_subnet.b.id]
|
||||
security_groups = [aws_security_group.app.id]
|
||||
assign_public_ip = true
|
||||
}
|
||||
|
||||
load_balancer {
|
||||
target_group_arn = aws_lb_target_group.app.arn
|
||||
container_name = "stirling"
|
||||
container_port = 8080
|
||||
}
|
||||
|
||||
depends_on = [aws_lb_listener.http]
|
||||
}
|
||||
|
||||
# ----- autoscaling -----
|
||||
resource "aws_appautoscaling_target" "app" {
|
||||
max_capacity = 10
|
||||
min_capacity = var.app_count
|
||||
resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.app.name}"
|
||||
scalable_dimension = "ecs:service:DesiredCount"
|
||||
service_namespace = "ecs"
|
||||
}
|
||||
|
||||
resource "aws_appautoscaling_policy" "cpu" {
|
||||
name = "${var.name}-cpu-target"
|
||||
policy_type = "TargetTrackingScaling"
|
||||
resource_id = aws_appautoscaling_target.app.resource_id
|
||||
scalable_dimension = aws_appautoscaling_target.app.scalable_dimension
|
||||
service_namespace = aws_appautoscaling_target.app.service_namespace
|
||||
|
||||
target_tracking_scaling_policy_configuration {
|
||||
target_value = 70
|
||||
predefined_metric_specification {
|
||||
predefined_metric_type = "ECSServiceAverageCPUUtilization"
|
||||
}
|
||||
scale_in_cooldown = 60
|
||||
scale_out_cooldown = 60
|
||||
}
|
||||
}
|
||||
|
||||
# ----- AI engine tier (internal LB + task def + service) -----
|
||||
resource "aws_lb" "engine" {
|
||||
count = var.enable_ai_engine ? 1 : 0
|
||||
name = "${var.name}-engine-alb"
|
||||
internal = true
|
||||
load_balancer_type = "application"
|
||||
subnets = [aws_subnet.a.id, aws_subnet.b.id]
|
||||
security_groups = [aws_security_group.engine_lb[0].id]
|
||||
}
|
||||
|
||||
resource "aws_lb_target_group" "engine" {
|
||||
count = var.enable_ai_engine ? 1 : 0
|
||||
name = "${var.name}-engine-tg"
|
||||
vpc_id = aws_vpc.main.id
|
||||
port = 5001
|
||||
protocol = "HTTP"
|
||||
target_type = "ip"
|
||||
|
||||
health_check {
|
||||
path = "/health"
|
||||
matcher = "200"
|
||||
interval = 30
|
||||
timeout = 10
|
||||
healthy_threshold = 2
|
||||
unhealthy_threshold = 5
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_lb_listener" "engine" {
|
||||
count = var.enable_ai_engine ? 1 : 0
|
||||
load_balancer_arn = aws_lb.engine[0].arn
|
||||
port = 5001
|
||||
protocol = "HTTP"
|
||||
|
||||
default_action {
|
||||
type = "forward"
|
||||
target_group_arn = aws_lb_target_group.engine[0].arn
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_ecs_task_definition" "engine" {
|
||||
count = var.enable_ai_engine ? 1 : 0
|
||||
family = "${var.name}-engine"
|
||||
cpu = 1024
|
||||
memory = 2048
|
||||
network_mode = "awsvpc"
|
||||
requires_compatibilities = ["FARGATE"]
|
||||
execution_role_arn = aws_iam_role.exec.arn
|
||||
|
||||
container_definitions = jsonencode([{
|
||||
name = "engine"
|
||||
image = var.engine_image
|
||||
essential = true
|
||||
portMappings = [{
|
||||
containerPort = 5001
|
||||
protocol = "tcp"
|
||||
}]
|
||||
logConfiguration = {
|
||||
logDriver = "awslogs"
|
||||
options = {
|
||||
awslogs-group = aws_cloudwatch_log_group.ecs.name
|
||||
awslogs-region = var.region
|
||||
awslogs-stream-prefix = "engine"
|
||||
}
|
||||
}
|
||||
secrets = [
|
||||
{ name = "STIRLING_ENGINE_SHARED_SECRET", valueFrom = "${aws_secretsmanager_secret.bundle.arn}:engineSharedSecret::" },
|
||||
]
|
||||
}])
|
||||
}
|
||||
|
||||
resource "aws_ecs_service" "engine" {
|
||||
count = var.enable_ai_engine ? 1 : 0
|
||||
name = "${var.name}-engine"
|
||||
cluster = aws_ecs_cluster.main.id
|
||||
task_definition = aws_ecs_task_definition.engine[0].arn
|
||||
desired_count = var.engine_count
|
||||
launch_type = "FARGATE"
|
||||
# Engine model-load warm-up can take 60-90s.
|
||||
health_check_grace_period_seconds = 120
|
||||
|
||||
network_configuration {
|
||||
subnets = [aws_subnet.a.id, aws_subnet.b.id]
|
||||
security_groups = [aws_security_group.engine[0].id]
|
||||
assign_public_ip = true
|
||||
}
|
||||
|
||||
load_balancer {
|
||||
target_group_arn = aws_lb_target_group.engine[0].arn
|
||||
container_name = "engine"
|
||||
container_port = 5001
|
||||
}
|
||||
|
||||
depends_on = [aws_lb_listener.engine]
|
||||
}
|
||||
|
||||
# ----- outputs -----
|
||||
output "app_url" {
|
||||
value = "http://${aws_lb.alb.dns_name}/"
|
||||
}
|
||||
|
||||
output "valkey_endpoint" {
|
||||
value = aws_elasticache_replication_group.valkey.primary_endpoint_address
|
||||
}
|
||||
|
||||
output "postgres_endpoint" {
|
||||
value = aws_db_instance.pg.endpoint
|
||||
}
|
||||
|
||||
output "cluster_name" {
|
||||
value = aws_ecs_cluster.main.name
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
apiVersion: v2
|
||||
name: stirling-pdf
|
||||
description: Stirling-PDF clustered deployment (web + worker + AI engine + Valkey).
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "2.11.0"
|
||||
@@ -1,44 +0,0 @@
|
||||
Stirling-PDF release "{{ .Release.Name }}" has been deployed.
|
||||
|
||||
{{- if .Values.ingress.enabled }}
|
||||
Front door: https://{{ .Values.ingress.host }}/ (via Ingress class "{{ .Values.ingress.className }}")
|
||||
{{- if not .Values.ingress.tls.enabled }}
|
||||
|
||||
WARNING: TLS is disabled on the Ingress (ingress.tls.enabled=false).
|
||||
Cookies, JWTs, and API keys will cross the wire in plaintext. Acceptable
|
||||
for dev / loopback only. Re-enable TLS and provide a secret named
|
||||
"{{ .Values.ingress.tls.secretName }}" (cert-manager or kubectl create secret tls).
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- if and .Values.cluster.enabled (eq .Values.cluster.backplane "valkey") .Values.cluster.valkey.bundled }}
|
||||
|
||||
WARNING: bundled Valkey is a single-replica StatefulSet (SPOF) - DEV / EVAL ONLY.
|
||||
Production deployments MUST use an external HA Valkey/Redis: set
|
||||
cluster.valkey.bundled=false
|
||||
cluster.valkey.externalUrl=rediss://<managed-endpoint>:6379
|
||||
Managed options: AWS ElastiCache for Valkey, GCP Memorystore, the Valkey
|
||||
Operator with multi-replica + Sentinel, or any other HA Redis-protocol service.
|
||||
See deploy/aws/README.md for the recommended managed paths.
|
||||
{{- end }}
|
||||
|
||||
{{- if and .Values.cluster.enabled (not .Values.cluster.engineSharedSecret) }}
|
||||
|
||||
INFO: cluster.engineSharedSecret was auto-generated and stored in the Secret
|
||||
"{{ .Release.Name }}-cluster-secrets". It is stable across `helm upgrade`
|
||||
(the chart reads the existing Secret on each render) but will regenerate on
|
||||
`helm uninstall` + reinstall. To pin it, retrieve with:
|
||||
kubectl get secret {{ .Release.Name }}-cluster-secrets \
|
||||
-o jsonpath='{.data.engineSharedSecret}' | base64 -d
|
||||
and store it in your secret manager / values file for future installs.
|
||||
{{- end }}
|
||||
|
||||
{{- if and .Values.cluster.enabled (eq .Values.cluster.backplane "valkey") .Values.cluster.valkey.bundled (not .Values.cluster.valkey.password) }}
|
||||
|
||||
INFO: cluster.valkey.password was auto-generated for the bundled Valkey and
|
||||
stored in "{{ .Release.Name }}-cluster-secrets". Stable across upgrades but
|
||||
regenerates on uninstall + reinstall (which would orphan the persisted
|
||||
Valkey PVC). Pin it explicitly before going beyond eval/dev:
|
||||
kubectl get secret {{ .Release.Name }}-cluster-secrets \
|
||||
-o jsonpath='{.data.valkeyPassword}' | base64 -d
|
||||
{{- end }}
|
||||
@@ -1,13 +0,0 @@
|
||||
{{/* Resolve the Valkey URL - bundled vs. external bring-your-own.
|
||||
Bundled path uses ${REDIS_PASSWORD} (shell expansion at Spring Boot startup) so the
|
||||
password is never written into the rendered manifest or K8s events.
|
||||
*/}}
|
||||
{{- define "stirling-pdf.valkeyUrl" -}}
|
||||
{{- if and .Values.cluster.enabled .Values.cluster.valkey.bundled -}}
|
||||
redis://:${REDIS_PASSWORD}@{{ .Release.Name }}-valkey:6379
|
||||
{{- else if .Values.cluster.valkey.externalUrl -}}
|
||||
{{ .Values.cluster.valkey.externalUrl }}
|
||||
{{- else if .Values.cluster.enabled -}}
|
||||
{{ fail "cluster.valkey.bundled=false requires cluster.valkey.externalUrl to be set (e.g. rediss://user:pw@host:6379)" }}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
@@ -1,62 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-engine
|
||||
spec:
|
||||
replicas: {{ .Values.engine.replicas }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: stirling-pdf
|
||||
role: engine
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: stirling-pdf
|
||||
role: engine
|
||||
spec:
|
||||
containers:
|
||||
- name: engine
|
||||
image: {{ .Values.engine.image.repository }}:{{ .Values.engine.image.tag | default .Chart.AppVersion }}
|
||||
env:
|
||||
- name: STIRLING_ENGINE_SHARED_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-cluster-secrets
|
||||
key: engineSharedSecret
|
||||
ports:
|
||||
- containerPort: 5001
|
||||
# Startup probe: 30 x 5s = 150s grace for model-load warm-up. Requires k8s >= 1.18.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 5001
|
||||
failureThreshold: 30
|
||||
periodSeconds: 5
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 5001
|
||||
periodSeconds: 10
|
||||
failureThreshold: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 5001
|
||||
periodSeconds: 10
|
||||
failureThreshold: 10
|
||||
{{- with .Values.engine.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-engine
|
||||
spec:
|
||||
selector:
|
||||
app: stirling-pdf
|
||||
role: engine
|
||||
ports:
|
||||
- port: 5001
|
||||
targetPort: 5001
|
||||
@@ -1,41 +0,0 @@
|
||||
{{- if .Values.web.autoscaling.enabled }}
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-web
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ .Release.Name }}-web
|
||||
minReplicas: {{ .Values.web.autoscaling.minReplicas }}
|
||||
maxReplicas: {{ .Values.web.autoscaling.maxReplicas }}
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.web.autoscaling.targetCPUUtilizationPercentage }}
|
||||
{{- end }}
|
||||
---
|
||||
{{- if .Values.worker.autoscaling.enabled }}
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-worker
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ .Release.Name }}-worker
|
||||
minReplicas: {{ .Values.worker.autoscaling.minReplicas }}
|
||||
maxReplicas: {{ .Values.worker.autoscaling.maxReplicas }}
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.worker.autoscaling.targetCPUUtilizationPercentage }}
|
||||
{{- end }}
|
||||
@@ -1,45 +0,0 @@
|
||||
{{- if .Values.ingress.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ .Release.Name }}
|
||||
annotations:
|
||||
# Cookie affinity is required - see deploy/aws/README.md "Sticky sessions are required".
|
||||
nginx.ingress.kubernetes.io/affinity: "cookie"
|
||||
nginx.ingress.kubernetes.io/affinity-mode: "persistent"
|
||||
nginx.ingress.kubernetes.io/session-cookie-name: "STIRLING_NODE"
|
||||
nginx.ingress.kubernetes.io/session-cookie-max-age: "86400"
|
||||
# Match the JVM-side 2000MB multipart limit (nginx-ingress default 1MB would 413).
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "2000m"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
|
||||
# Defense-in-depth: /internal/* is never a public route.
|
||||
nginx.ingress.kubernetes.io/server-snippet: |
|
||||
location ^~ /internal/ { return 404; }
|
||||
spec:
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- if .Values.ingress.tls.enabled }}
|
||||
{{- if not .Values.ingress.tls.secretName }}
|
||||
{{- fail "ingress.tls.enabled=true requires ingress.tls.secretName to be set" }}
|
||||
{{- end }}
|
||||
tls:
|
||||
- hosts:
|
||||
{{- if .Values.ingress.tls.hosts }}
|
||||
{{- toYaml .Values.ingress.tls.hosts | nindent 8 }}
|
||||
{{- else }}
|
||||
- {{ .Values.ingress.host }}
|
||||
{{- end }}
|
||||
secretName: {{ .Values.ingress.tls.secretName }}
|
||||
{{- end }}
|
||||
rules:
|
||||
- host: {{ .Values.ingress.host }}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ .Release.Name }}-web
|
||||
port:
|
||||
number: 8080
|
||||
{{- end }}
|
||||
@@ -1,59 +0,0 @@
|
||||
{{/*
|
||||
Stable-secret pattern. Resolution order for each field:
|
||||
1. Operator-provided value (.Values.*) wins.
|
||||
2. Otherwise reuse what is already in the cluster Secret (lookup), so
|
||||
`helm upgrade` does NOT churn the value on every run.
|
||||
3. Otherwise generate a fresh 64-char random value (first install).
|
||||
|
||||
Caveats:
|
||||
- `lookup` returns empty during `helm template` / `--dry-run=client`. That
|
||||
is acceptable here: those modes are for inspection, not source of truth.
|
||||
Real `helm install` / `helm upgrade` against a live API server see the
|
||||
existing Secret.
|
||||
- `helm uninstall` removes this Secret. A subsequent `helm install` with no
|
||||
operator override will generate fresh values, and any persisted data tied
|
||||
to the old Valkey password (PVC contents) will be unreadable.
|
||||
*/}}
|
||||
{{- $secretName := printf "%s-cluster-secrets" .Release.Name }}
|
||||
{{- $existing := lookup "v1" "Secret" .Release.Namespace $secretName }}
|
||||
{{- $existingEngine := "" }}
|
||||
{{- $existingValkey := "" }}
|
||||
{{- if $existing }}
|
||||
{{- if hasKey $existing.data "engineSharedSecret" }}
|
||||
{{- $existingEngine = index $existing.data "engineSharedSecret" | b64dec }}
|
||||
{{- end }}
|
||||
{{- if hasKey $existing.data "valkeyPassword" }}
|
||||
{{- $existingValkey = index $existing.data "valkeyPassword" | b64dec }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- $engineSecret := "" }}
|
||||
{{- if .Values.cluster.engineSharedSecret }}
|
||||
{{- $engineSecret = .Values.cluster.engineSharedSecret }}
|
||||
{{- else if $existingEngine }}
|
||||
{{- $engineSecret = $existingEngine }}
|
||||
{{- else }}
|
||||
{{- $engineSecret = randAlphaNum 64 }}
|
||||
{{- end }}
|
||||
|
||||
{{- $needsBundledValkey := and .Values.cluster.enabled (eq .Values.cluster.backplane "valkey") .Values.cluster.valkey.bundled }}
|
||||
{{- $valkeyPassword := "" }}
|
||||
{{- if $needsBundledValkey }}
|
||||
{{- if .Values.cluster.valkey.password }}
|
||||
{{- $valkeyPassword = .Values.cluster.valkey.password }}
|
||||
{{- else if $existingValkey }}
|
||||
{{- $valkeyPassword = $existingValkey }}
|
||||
{{- else }}
|
||||
{{- $valkeyPassword = randAlphaNum 64 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ $secretName }}
|
||||
type: Opaque
|
||||
stringData:
|
||||
engineSharedSecret: {{ $engineSecret | quote }}
|
||||
{{- if $needsBundledValkey }}
|
||||
valkeyPassword: {{ $valkeyPassword | quote }}
|
||||
{{- end }}
|
||||
@@ -1,80 +0,0 @@
|
||||
{{- if and .Values.cluster.enabled .Values.cluster.valkey.bundled }}
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-valkey
|
||||
spec:
|
||||
serviceName: {{ .Release.Name }}-valkey
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: stirling-valkey
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: stirling-valkey
|
||||
spec:
|
||||
containers:
|
||||
- name: valkey
|
||||
image: valkey/valkey:8.0-alpine
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- "exec valkey-server --requirepass \"$REDIS_PASSWORD\" --maxmemory 256mb --maxmemory-policy allkeys-lru"
|
||||
env:
|
||||
- name: REDIS_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-cluster-secrets
|
||||
key: valkeyPassword
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- "valkey-cli -a \"$REDIS_PASSWORD\" --no-auth-warning ping"
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- "valkey-cli -a \"$REDIS_PASSWORD\" --no-auth-warning ping"
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
{{- toYaml .Values.cluster.valkey.resources | nindent 12 }}
|
||||
{{- if .Values.cluster.valkey.persistence.enabled }}
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
{{- end }}
|
||||
{{- if .Values.cluster.valkey.persistence.enabled }}
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
{{- if .Values.cluster.valkey.persistence.storageClassName }}
|
||||
storageClassName: {{ .Values.cluster.valkey.persistence.storageClassName }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.cluster.valkey.persistence.size }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-valkey
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: stirling-valkey
|
||||
ports:
|
||||
- port: 6379
|
||||
targetPort: 6379
|
||||
{{- end }}
|
||||
@@ -1,75 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-web
|
||||
labels:
|
||||
app: stirling-pdf
|
||||
role: web
|
||||
spec:
|
||||
replicas: {{ .Values.web.replicas }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: stirling-pdf
|
||||
role: web
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: stirling-pdf
|
||||
role: web
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
image: {{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
env:
|
||||
- name: MODE
|
||||
value: FRONTEND
|
||||
- name: CLUSTER_ENABLED
|
||||
value: {{ .Values.cluster.enabled | quote }}
|
||||
- name: CLUSTER_BACKPLANE
|
||||
value: {{ .Values.cluster.backplane }}
|
||||
{{- if and .Values.cluster.enabled (eq .Values.cluster.backplane "valkey") .Values.cluster.valkey.bundled }}
|
||||
- name: REDIS_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-cluster-secrets
|
||||
key: valkeyPassword
|
||||
{{- end }}
|
||||
- name: CLUSTER_VALKEY_URL
|
||||
value: {{ include "stirling-pdf.valkeyUrl" . | quote }}
|
||||
- name: CLUSTER_NODE_ROLE
|
||||
value: web
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: status.podIP
|
||||
- name: CLUSTER_ENGINE_SHAREDSECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-cluster-secrets
|
||||
key: engineSharedSecret
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
# Startup probe: 30 x 5s = 150s grace for Spring Boot + Valkey warm-up. Requires k8s >= 1.18.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /api/v1/info/status
|
||||
port: 8080
|
||||
failureThreshold: 30
|
||||
periodSeconds: 5
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/info/status
|
||||
port: 8080
|
||||
periodSeconds: 10
|
||||
failureThreshold: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/info/status
|
||||
port: 8080
|
||||
periodSeconds: 10
|
||||
failureThreshold: 10
|
||||
{{- with .Values.web.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
@@ -1,11 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-web
|
||||
spec:
|
||||
selector:
|
||||
app: stirling-pdf
|
||||
role: web
|
||||
ports:
|
||||
- port: 8080
|
||||
targetPort: 8080
|
||||
@@ -1,75 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-worker
|
||||
labels:
|
||||
app: stirling-pdf
|
||||
role: worker
|
||||
spec:
|
||||
replicas: {{ .Values.worker.replicas }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: stirling-pdf
|
||||
role: worker
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: stirling-pdf
|
||||
role: worker
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
image: {{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
env:
|
||||
- name: MODE
|
||||
value: BACKEND
|
||||
- name: CLUSTER_ENABLED
|
||||
value: {{ .Values.cluster.enabled | quote }}
|
||||
- name: CLUSTER_BACKPLANE
|
||||
value: {{ .Values.cluster.backplane }}
|
||||
{{- if and .Values.cluster.enabled (eq .Values.cluster.backplane "valkey") .Values.cluster.valkey.bundled }}
|
||||
- name: REDIS_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-cluster-secrets
|
||||
key: valkeyPassword
|
||||
{{- end }}
|
||||
- name: CLUSTER_VALKEY_URL
|
||||
value: {{ include "stirling-pdf.valkeyUrl" . | quote }}
|
||||
- name: CLUSTER_NODE_ROLE
|
||||
value: worker
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: status.podIP
|
||||
- name: CLUSTER_ENGINE_SHAREDSECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-cluster-secrets
|
||||
key: engineSharedSecret
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
# Startup probe: 30 x 5s = 150s grace for Spring Boot + Valkey warm-up. Requires k8s >= 1.18.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /api/v1/info/status
|
||||
port: 8080
|
||||
failureThreshold: 30
|
||||
periodSeconds: 5
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/info/status
|
||||
port: 8080
|
||||
periodSeconds: 10
|
||||
failureThreshold: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/info/status
|
||||
port: 8080
|
||||
periodSeconds: 10
|
||||
failureThreshold: 10
|
||||
{{- with .Values.worker.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
@@ -1,91 +0,0 @@
|
||||
# Stirling-PDF Helm chart - clustered install (web + worker + AI engine + Valkey behind Ingress).
|
||||
# Single-instance deploys can run the standard container directly without this chart.
|
||||
|
||||
image:
|
||||
repository: stirlingtools/stirling-pdf
|
||||
# Leave blank to default to .Chart.AppVersion. Override per release.
|
||||
tag: ""
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
engine:
|
||||
image:
|
||||
repository: stirlingtools/stirling-pdf-ai-engine
|
||||
tag: ""
|
||||
replicas: 2
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: 1Gi
|
||||
|
||||
web:
|
||||
replicas: 2
|
||||
autoscaling:
|
||||
enabled: true
|
||||
minReplicas: 2
|
||||
maxReplicas: 10
|
||||
targetCPUUtilizationPercentage: 70
|
||||
resources:
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: 2Gi
|
||||
|
||||
worker:
|
||||
replicas: 2
|
||||
autoscaling:
|
||||
enabled: true
|
||||
minReplicas: 2
|
||||
maxReplicas: 10
|
||||
targetCPUUtilizationPercentage: 70
|
||||
resources:
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: 2Gi
|
||||
|
||||
cluster:
|
||||
enabled: true
|
||||
backplane: valkey
|
||||
valkey:
|
||||
# WARNING: bundled Valkey is a single-replica StatefulSet (SPOF) - DEV / EVAL ONLY.
|
||||
# Production: set bundled=false and point externalUrl at HA Valkey/Redis.
|
||||
# See deploy/aws/README.md for managed options (ElastiCache, Memorystore, etc.).
|
||||
bundled: true
|
||||
# Required when bundled=false. Use rediss:// for TLS.
|
||||
externalUrl: ""
|
||||
# Auto-generated on first install if empty (stable across upgrades via Secret lookup).
|
||||
# Set explicitly via --set or values file when using your own secret manager.
|
||||
password: ""
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 1Gi
|
||||
storageClassName: ""
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 384Mi
|
||||
# Java <-> AI engine auth secret. Auto-generated if empty (see NOTES.txt for retrieval).
|
||||
engineSharedSecret: ""
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
host: stirling.example.com
|
||||
className: nginx
|
||||
tls:
|
||||
# WARNING: TLS MUST be on in production - cookies, JWTs, and API keys cross the wire.
|
||||
# Provision the secret below (cert-manager, kubectl create secret tls, etc.) before helm install.
|
||||
# tls.enabled=false is HTTP-only - dev/loopback ONLY.
|
||||
enabled: true
|
||||
secretName: stirling-pdf-tls
|
||||
# Defaults to [ingress.host] when empty.
|
||||
hosts: []
|
||||
@@ -1,175 +0,0 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {"type": "grafana", "uid": "-- Grafana --"},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "Starter dashboard for Stirling-PDF cluster mode (plan §7). Add to your Grafana instance pointed at a Prometheus that is scraping /actuator/prometheus on each node. NOTE: requires PR3 (Valkey backplane impls + ClusterMetrics) - panels render 'No data' until that lands.",
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 1,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"fieldConfig": {"defaults": {"color": {"mode": "palette-classic"}, "unit": "short"}},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
|
||||
"id": 1,
|
||||
"options": {"legend": {"displayMode": "table", "showLegend": true}},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"expr": "stirling_cluster_jobs_inflight",
|
||||
"legendFormat": "{{node}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Jobs in flight (per node)",
|
||||
"type": "timeseries",
|
||||
"description": "Requires PR3 (Valkey backplane impls + ClusterMetrics) - import after PR3 lands."
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"fieldConfig": {"defaults": {"color": {"mode": "palette-classic"}, "unit": "short"}},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
|
||||
"id": 2,
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"expr": "stirling_cluster_queue_depth",
|
||||
"legendFormat": "{{lane}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Queue depth (per lane)",
|
||||
"type": "timeseries",
|
||||
"description": "Requires PR3 (Valkey backplane impls + ClusterMetrics) - import after PR3 lands."
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"fieldConfig": {"defaults": {"color": {"mode": "palette-classic"}, "unit": "s"}},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
|
||||
"id": 3,
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"expr": "histogram_quantile(0.5, sum(rate(stirling_cluster_job_wait_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p50",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"expr": "histogram_quantile(0.95, sum(rate(stirling_cluster_job_wait_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p95",
|
||||
"refId": "B"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"expr": "histogram_quantile(0.99, sum(rate(stirling_cluster_job_wait_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p99",
|
||||
"refId": "C"
|
||||
}
|
||||
],
|
||||
"title": "Job wait time (p50/p95/p99)",
|
||||
"type": "timeseries",
|
||||
"description": "Requires PR3 (Valkey backplane impls + ClusterMetrics) - import after PR3 lands."
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"fieldConfig": {"defaults": {"color": {"mode": "palette-classic"}, "unit": "s"}},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 8},
|
||||
"id": 4,
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"expr": "histogram_quantile(0.5, sum(rate(stirling_cluster_backplane_latency_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p50",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"expr": "histogram_quantile(0.95, sum(rate(stirling_cluster_backplane_latency_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p95",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"title": "Backplane round-trip latency (Valkey p50/p95)",
|
||||
"type": "timeseries",
|
||||
"description": "Requires PR3 (Valkey backplane impls + ClusterMetrics) - import after PR3 lands."
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"fieldConfig": {"defaults": {"color": {"mode": "palette-classic"}, "unit": "ops"}},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 16},
|
||||
"id": 5,
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"expr": "sum(rate(stirling_cluster_sticky_miss_total[5m]))",
|
||||
"legendFormat": "sticky-session misses / sec",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Sticky-session miss rate (high = LB affinity broken)",
|
||||
"type": "timeseries",
|
||||
"description": "Requires PR3 (Valkey backplane impls + ClusterMetrics) - import after PR3 lands."
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"fieldConfig": {"defaults": {"color": {"mode": "palette-classic"}, "unit": "ops"}},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 16},
|
||||
"id": 6,
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"expr": "sum(rate(stirling_cluster_ratelimit_rejected_total[5m]))",
|
||||
"legendFormat": "rejections / sec",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Rate-limit rejections (cluster-wide)",
|
||||
"type": "timeseries",
|
||||
"description": "Requires PR3 (Valkey backplane impls + ClusterMetrics) - import after PR3 lands."
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 38,
|
||||
"style": "dark",
|
||||
"tags": ["stirling-pdf", "cluster"],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"current": {"selected": false, "text": "Prometheus", "value": "Prometheus"},
|
||||
"hide": 0,
|
||||
"includeAll": false,
|
||||
"label": "Datasource",
|
||||
"multi": false,
|
||||
"name": "DS_PROMETHEUS",
|
||||
"options": [],
|
||||
"query": "prometheus",
|
||||
"queryValue": "",
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"type": "datasource"
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": {"from": "now-1h", "to": "now"},
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "Stirling-PDF Cluster",
|
||||
"uid": "stirling-cluster-phase1",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
# Stirling-PDF unified container. MODE env var selects role: FRONTEND, BACKEND, or BOTH.
|
||||
|
||||
ARG BASE_VERSION=1.0.2
|
||||
ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION}
|
||||
|
||||
# Stage 1: Build the Java application
|
||||
FROM gradle:9.3.1-jdk25 AS app-build
|
||||
|
||||
ARG TASK_VERSION=3.49.1
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& update-ca-certificates \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& ARCH=$(dpkg --print-architecture) \
|
||||
&& curl -fsSL "https://github.com/go-task/task/releases/download/v${TASK_VERSION}/task_${TASK_VERSION}_linux_${ARCH}.deb" -o /tmp/task.deb \
|
||||
&& dpkg -i /tmp/task.deb \
|
||||
&& rm /tmp/task.deb \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV JDK_JAVA_OPTIONS="--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY build.gradle settings.gradle gradlew ./
|
||||
COPY gradle/ gradle/
|
||||
COPY app/core/build.gradle app/core/
|
||||
COPY app/common/build.gradle app/common/
|
||||
COPY app/proprietary/build.gradle app/proprietary/
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG STIRLING_FLAVOR=proprietary
|
||||
ENV STIRLING_FLAVOR=${STIRLING_FLAVOR}
|
||||
|
||||
RUN STIRLING_FLAVOR=${STIRLING_FLAVOR} \
|
||||
gradle clean build \
|
||||
-PbuildWithFrontend=true \
|
||||
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
|
||||
--no-daemon
|
||||
|
||||
# Stage 2: Extract Spring Boot layers
|
||||
FROM eclipse-temurin:25-jre-noble AS jar-extract
|
||||
WORKDIR /tmp
|
||||
COPY --from=app-build /app/app/core/build/libs/*.jar app.jar
|
||||
RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
ARG VERSION_TAG
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --link --from=jar-extract --chown=1000:1000 /layers/dependencies/ /app/
|
||||
COPY --link --from=jar-extract --chown=1000:1000 /layers/spring-boot-loader/ /app/
|
||||
COPY --link --from=jar-extract --chown=1000:1000 /layers/snapshot-dependencies/ /app/
|
||||
COPY --link --from=jar-extract --chown=1000:1000 /layers/application/ /app/
|
||||
|
||||
COPY --link --chown=1000:1000 scripts/ /scripts/
|
||||
|
||||
ENV MODE=BOTH \
|
||||
BACKEND_INTERNAL_PORT=8081 \
|
||||
STIRLING_AOT_ENABLE="false" \
|
||||
STIRLING_JVM_PROFILE="balanced" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
HOME=/home/stirlingpdfuser \
|
||||
PUID=1000 \
|
||||
PGID=1000 \
|
||||
UMASK=022 \
|
||||
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
|
||||
TMPDIR=/tmp/stirling-pdf
|
||||
|
||||
RUN echo "${VERSION_TAG:-dev}" > /etc/stirling_version
|
||||
|
||||
LABEL org.opencontainers.image.title="Stirling-PDF Unified" \
|
||||
org.opencontainers.image.description="Unified container - selectable role via MODE env var." \
|
||||
org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF" \
|
||||
org.opencontainers.image.licenses="MIT" \
|
||||
org.opencontainers.image.vendor="Stirling-Tools" \
|
||||
org.opencontainers.image.version="${VERSION_TAG}"
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
STOPSIGNAL SIGTERM
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=15s --start-period=120s --retries=5 \
|
||||
CMD curl -fs --max-time 10 http://localhost:8080${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status || exit 1
|
||||
|
||||
# Map MODE to cluster node role at container start.
|
||||
ENTRYPOINT ["sh", "-c", "case \"$MODE\" in BACKEND) export CLUSTER_NODE_ROLE=worker;; FRONTEND) export CLUSTER_NODE_ROLE=web;; *) export CLUSTER_NODE_ROLE=both;; esac; exec tini -- /scripts/init.sh"]
|
||||
CMD []
|
||||
@@ -1,194 +0,0 @@
|
||||
# Reference clustered deployment: load balancer + N app + N engine + Valkey + Postgres.
|
||||
# Non-k8s enterprise self-host blueprint. /internal/* stays off the LB.
|
||||
#
|
||||
# Required env vars (set in a .env beside this file or via your secret store):
|
||||
# CLUSTER_ENGINE_SHAREDSECRET - generate: openssl rand -hex 32
|
||||
# STIRLING_VALKEY_PASSWORD - generate: openssl rand -hex 32
|
||||
# POSTGRES_PASSWORD - required
|
||||
# STIRLING_PREMIUM_KEY - SERVER/ENTERPRISE license key (cluster mode is a paid feature)
|
||||
|
||||
services:
|
||||
valkey:
|
||||
image: valkey/valkey:8.0-alpine
|
||||
container_name: stirling-valkey
|
||||
restart: unless-stopped
|
||||
command:
|
||||
[
|
||||
"valkey-server",
|
||||
"--requirepass",
|
||||
"${STIRLING_VALKEY_PASSWORD:?STIRLING_VALKEY_PASSWORD must be set in production}",
|
||||
"--maxmemory",
|
||||
"256mb",
|
||||
"--maxmemory-policy",
|
||||
"allkeys-lru"
|
||||
]
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"valkey-cli",
|
||||
"-a",
|
||||
"${STIRLING_VALKEY_PASSWORD:?required}",
|
||||
"--no-auth-warning",
|
||||
"ping"
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
# Do not publish 6379 in prod. Uncomment only for local valkey-cli introspection.
|
||||
# ports:
|
||||
# - "6379:6379"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 384m
|
||||
reservations:
|
||||
memory: 128m
|
||||
volumes:
|
||||
- valkey-data:/data
|
||||
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
container_name: stirling-postgres
|
||||
environment:
|
||||
POSTGRES_USER: stirling
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
|
||||
POSTGRES_DB: stirling
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1g
|
||||
reservations:
|
||||
memory: 256m
|
||||
|
||||
app-1:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: docker/Dockerfile.unified
|
||||
container_name: stirling-app-1
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MODE: BOTH
|
||||
CLUSTER_ENABLED: "true"
|
||||
CLUSTER_BACKPLANE: valkey
|
||||
CLUSTER_VALKEY_URL: redis://:${STIRLING_VALKEY_PASSWORD:?required}@valkey:6379
|
||||
CLUSTER_NODE_ID: app-1
|
||||
CLUSTER_NODE_INTERNALADDRESS: app-1:8080
|
||||
CLUSTER_ENGINE_SHAREDSECRET: ${CLUSTER_ENGINE_SHAREDSECRET:?required}
|
||||
AIENGINE_URL: http://engine-lb:5001
|
||||
STIRLING_PREMIUM_KEY: ${STIRLING_PREMIUM_KEY:?STIRLING_PREMIUM_KEY required for cluster mode}
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/stirling
|
||||
SPRING_DATASOURCE_USERNAME: stirling
|
||||
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:?required}
|
||||
depends_on:
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_started
|
||||
engine-lb:
|
||||
condition: service_started
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2g
|
||||
reservations:
|
||||
memory: 512m
|
||||
|
||||
app-2:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: docker/Dockerfile.unified
|
||||
container_name: stirling-app-2
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MODE: BOTH
|
||||
CLUSTER_ENABLED: "true"
|
||||
CLUSTER_BACKPLANE: valkey
|
||||
CLUSTER_VALKEY_URL: redis://:${STIRLING_VALKEY_PASSWORD:?required}@valkey:6379
|
||||
CLUSTER_NODE_ID: app-2
|
||||
CLUSTER_NODE_INTERNALADDRESS: app-2:8080
|
||||
CLUSTER_ENGINE_SHAREDSECRET: ${CLUSTER_ENGINE_SHAREDSECRET:?required}
|
||||
AIENGINE_URL: http://engine-lb:5001
|
||||
STIRLING_PREMIUM_KEY: ${STIRLING_PREMIUM_KEY:?STIRLING_PREMIUM_KEY required for cluster mode}
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/stirling
|
||||
SPRING_DATASOURCE_USERNAME: stirling
|
||||
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:?required}
|
||||
depends_on:
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_started
|
||||
engine-lb:
|
||||
condition: service_started
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2g
|
||||
reservations:
|
||||
memory: 512m
|
||||
|
||||
engine-1:
|
||||
build:
|
||||
context: ../../engine
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: stirling-engine-1
|
||||
env_file: ../../engine/.env
|
||||
environment:
|
||||
STIRLING_ENGINE_SHARED_SECRET: ${CLUSTER_ENGINE_SHAREDSECRET:?required}
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1g
|
||||
|
||||
engine-2:
|
||||
build:
|
||||
context: ../../engine
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: stirling-engine-2
|
||||
env_file: ../../engine/.env
|
||||
environment:
|
||||
STIRLING_ENGINE_SHARED_SECRET: ${CLUSTER_ENGINE_SHAREDSECRET:?required}
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1g
|
||||
|
||||
# Internal round-robin LB for the engine tier - not exposed to the host.
|
||||
engine-lb:
|
||||
image: nginx:1.27-alpine
|
||||
container_name: stirling-engine-lb
|
||||
volumes:
|
||||
- ./engine-nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
depends_on:
|
||||
- engine-1
|
||||
- engine-2
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 64m
|
||||
|
||||
lb:
|
||||
image: nginx:1.27-alpine
|
||||
container_name: stirling-lb
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
depends_on:
|
||||
- app-1
|
||||
- app-2
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128m
|
||||
|
||||
volumes:
|
||||
valkey-data:
|
||||
postgres-data:
|
||||
@@ -1,27 +0,0 @@
|
||||
# nginx LB for the AI engine tier of docker-compose-cluster.yml.
|
||||
# Round-robin is safe (engine is stateless). The separate LB hop exists because the JVM
|
||||
# caches DNS lookups for the process lifetime, so compose round-robin DNS would pin each
|
||||
# JVM to one engine and defeat scaling.
|
||||
|
||||
events { worker_connections 1024; }
|
||||
|
||||
http {
|
||||
upstream stirling_engine {
|
||||
server engine-1:5001;
|
||||
server engine-2:5001;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 5001;
|
||||
|
||||
# Matches JVM multipart cap; nginx default 1MB would 413.
|
||||
client_max_body_size 2000m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://stirling_engine;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
# nginx LB for docker-compose-cluster.yml.
|
||||
# Sends all traffic to the Java app (including /api/v1/ai/*, which the app proxies to
|
||||
# the engine with X-Engine-Auth attestation). /internal/* is 404'd. The engine tier
|
||||
# is never exposed directly to clients.
|
||||
|
||||
events { worker_connections 1024; }
|
||||
|
||||
http {
|
||||
upstream stirling_app {
|
||||
# Sticky sessions required - see deploy/aws/README.md for ip_hash caveat + alternatives.
|
||||
ip_hash;
|
||||
server app-1:8080;
|
||||
server app-2:8080;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
|
||||
# Matches JVM multipart.max-file-size; nginx default 1MB would 413.
|
||||
client_max_body_size 2000m;
|
||||
|
||||
location /internal/ {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://stirling_app;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml uv.lock ./
|
||||
COPY src/ ./src/
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen
|
||||
|
||||
|
||||
@@ -2672,6 +2672,47 @@ title = "Change Permissions"
|
||||
[changePermissions.tooltip.warning]
|
||||
text = "To make these permissions unchangeable, use the Add Password tool to set an owner password."
|
||||
|
||||
[agents]
|
||||
section_title = "Agents"
|
||||
fullscreen_title = "Stirling Agents"
|
||||
stirling_name = "Stirling"
|
||||
stirling_full_name = "Stirling General Agent"
|
||||
stirling_tooltip = "Stirling agent"
|
||||
stirling_description = "Your general-purpose PDF assistant"
|
||||
stirling_long_description = "General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents."
|
||||
back_to_tools = "Back to tools"
|
||||
coming_soon = "Coming soon"
|
||||
view_all = "View all agents"
|
||||
show_less = "Show less"
|
||||
start_chat = "Start chatting"
|
||||
|
||||
[chat.header]
|
||||
settings = "Agent settings"
|
||||
agentMenu = "Stirling agent options"
|
||||
clearChat = "Clear chat"
|
||||
|
||||
[chat.input]
|
||||
placeholder = "What do you want to do?"
|
||||
send = "Send message"
|
||||
attach = "Attach files"
|
||||
|
||||
[chat.quickActions]
|
||||
heading = "Get started"
|
||||
openFromComputer = "Open from computer"
|
||||
browseYourFiles = "Browse your files"
|
||||
rotateOne = "Rotate this document"
|
||||
rotateMany = "Rotate these documents"
|
||||
compressOne = "Compress this document"
|
||||
compressMany = "Compress these documents"
|
||||
mergeMany = "Merge these {{count}} documents into 1"
|
||||
splitOne = "Split this document"
|
||||
convertOne = "Convert this document to PDF"
|
||||
convertMany = "Convert these documents to PDF"
|
||||
fileSummary_one = "1 file in workbench ({{types}})"
|
||||
fileSummary_other = "{{count}} files in workbench ({{types}})"
|
||||
moreFiles = "+{{count}} more"
|
||||
removeFile = "Remove {{name}}"
|
||||
|
||||
[chat.progress]
|
||||
analyzing = "Analysing your request..."
|
||||
calling_engine = "AI is thinking..."
|
||||
@@ -6864,6 +6905,7 @@ theme = "Theme"
|
||||
themeDark = "Dark"
|
||||
themeDescription = "Switch between light and dark mode"
|
||||
themeLight = "Light"
|
||||
themeMidnight = "Midnight"
|
||||
title = "General"
|
||||
user = "User"
|
||||
|
||||
@@ -7910,6 +7952,11 @@ expand = "Expand panel"
|
||||
placeholder = "Choose a tool to get started"
|
||||
premiumFeature = "Premium feature:"
|
||||
search = "Search tools"
|
||||
toolsHeader = "Tools"
|
||||
viewAllTools = "View all tools"
|
||||
backToDefault = "Back"
|
||||
backToAllTools = "Back to all tools"
|
||||
goBack = "Go back"
|
||||
|
||||
[toolPanel.fullscreen]
|
||||
comingSoon = "Coming soon:"
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Finds and optionally removes unused CSS custom properties from theme.css.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/clean-unused-theme-vars.js # dry run: report only
|
||||
* node scripts/clean-unused-theme-vars.js --check # CI mode: exit 1 if any unused
|
||||
* node scripts/clean-unused-theme-vars.js --remove # remove unused declarations
|
||||
* node scripts/clean-unused-theme-vars.js --verbose # show all usage counts
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const REMOVE = process.argv.includes("--remove");
|
||||
const CHECK = process.argv.includes("--check");
|
||||
const VERBOSE =
|
||||
process.argv.includes("--verbose") || process.argv.includes("-v");
|
||||
|
||||
const EDITOR_ROOT = path.join(__dirname, "..");
|
||||
const THEME_CSS_PATH = path.join(EDITOR_ROOT, "src/core/styles/theme.css");
|
||||
|
||||
const SCAN_EXTENSIONS = new Set([
|
||||
".ts",
|
||||
".tsx",
|
||||
".css",
|
||||
".js",
|
||||
".jsx",
|
||||
".mts",
|
||||
".mjs",
|
||||
]);
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
"node_modules",
|
||||
"dist",
|
||||
".git",
|
||||
"target",
|
||||
"coverage",
|
||||
".turbo",
|
||||
]);
|
||||
|
||||
// ─── Parse declarations ───────────────────────────────────────────────────────
|
||||
|
||||
function parseDeclarations(content) {
|
||||
const lines = content.split("\n");
|
||||
// Map from varName -> array of 0-based line indices where it is declared
|
||||
const declarations = new Map();
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = lines[i].match(/^\s*(--[\w-]+)\s*:/);
|
||||
if (match) {
|
||||
const name = match[1];
|
||||
if (!declarations.has(name)) declarations.set(name, []);
|
||||
declarations.get(name).push(i);
|
||||
}
|
||||
}
|
||||
|
||||
return declarations;
|
||||
}
|
||||
|
||||
// ─── Scan for usages ─────────────────────────────────────────────────────────
|
||||
|
||||
const VAR_USAGE_RE = /var\((--[\w-]+)/g;
|
||||
|
||||
function collectUsagesInFile(filePath, used) {
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(filePath, "utf8");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
VAR_USAGE_RE.lastIndex = 0;
|
||||
let match;
|
||||
while ((match = VAR_USAGE_RE.exec(content)) !== null) {
|
||||
used.add(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
function scanDir(dir, used) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) continue;
|
||||
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
scanDir(fullPath, used);
|
||||
} else if (SCAN_EXTENSIONS.has(path.extname(entry.name))) {
|
||||
collectUsagesInFile(fullPath, used);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Remove unused declarations ───────────────────────────────────────────────
|
||||
|
||||
function removeDeclarations(content, unusedNames) {
|
||||
const lines = content.split("\n");
|
||||
const filtered = [];
|
||||
let skipping = false;
|
||||
|
||||
for (const line of lines) {
|
||||
if (skipping) {
|
||||
// Still consuming a multi-line value — stop when we hit the terminating semicolon
|
||||
if (line.includes(";")) skipping = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = line.match(/^\s*(--[\w-]+)\s*:/);
|
||||
if (match && unusedNames.has(match[1])) {
|
||||
// If the value doesn't end on this line, skip subsequent lines too
|
||||
if (!line.includes(";")) skipping = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
filtered.push(line);
|
||||
}
|
||||
|
||||
// Collapse runs of 3+ blank lines down to 2 (avoids gaps from removed blocks)
|
||||
const collapsed = [];
|
||||
let blankRun = 0;
|
||||
for (const line of filtered) {
|
||||
if (line.trim() === "") {
|
||||
blankRun++;
|
||||
if (blankRun <= 2) collapsed.push(line);
|
||||
} else {
|
||||
blankRun = 0;
|
||||
collapsed.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return collapsed.join("\n");
|
||||
}
|
||||
|
||||
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(THEME_CSS_PATH)) {
|
||||
console.error("theme.css not found at:", THEME_CSS_PATH);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const themeCss = fs.readFileSync(THEME_CSS_PATH, "utf8");
|
||||
const declarations = parseDeclarations(themeCss);
|
||||
|
||||
console.log(`Found ${declarations.size} declared CSS variables in theme.css`);
|
||||
console.log("Scanning source files for usages...");
|
||||
|
||||
const used = new Set();
|
||||
scanDir(EDITOR_ROOT, used);
|
||||
|
||||
const unused = new Set();
|
||||
for (const name of declarations.keys()) {
|
||||
if (!used.has(name)) unused.add(name);
|
||||
}
|
||||
|
||||
if (VERBOSE) {
|
||||
const allNames = [...declarations.keys()].sort();
|
||||
for (const name of allNames) {
|
||||
const status = used.has(name) ? "used " : "UNUSED";
|
||||
console.log(` ${status} ${name}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
if (unused.size === 0) {
|
||||
console.log("No unused CSS variables found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const sortedUnused = [...unused].sort();
|
||||
console.log(`\nUnused variables (${unused.size}):`);
|
||||
for (const name of sortedUnused) {
|
||||
const lineNums = declarations
|
||||
.get(name)
|
||||
.map((i) => i + 1)
|
||||
.join(", ");
|
||||
console.log(
|
||||
` ${name} (line${declarations.get(name).length > 1 ? "s" : ""} ${lineNums})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (CHECK) {
|
||||
console.error(
|
||||
"\ntheme.css has unused CSS variables. Run the following to fix:",
|
||||
);
|
||||
console.error("\n task frontend:theme:clean-unused-vars REMOVE=true\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!REMOVE) {
|
||||
console.log(
|
||||
"\nDry run — no changes written. Re-run with --remove to delete unused declarations.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = removeDeclarations(themeCss, unused);
|
||||
fs.writeFileSync(THEME_CSS_PATH, updated, "utf8");
|
||||
|
||||
// Count declarations removed
|
||||
const declsRemoved = [...unused].reduce(
|
||||
(sum, name) => sum + declarations.get(name).length,
|
||||
0,
|
||||
);
|
||||
console.log(
|
||||
`\nRemoved ${declsRemoved} declaration${declsRemoved !== 1 ? "s" : ""} (${unused.size} unique variable${unused.size !== 1 ? "s" : ""}) from theme.css`,
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Core stubs for the right-rail Agents UI.
|
||||
*
|
||||
* The real implementations live in {@code proprietary/components/agents/AgentsPanel.tsx}
|
||||
* and shadow these stubs via the {@code @app/*} alias cascade when the proprietary
|
||||
* build is active. Core builds render nothing, so the right rail collapses to the
|
||||
* tool list unchanged.
|
||||
*/
|
||||
|
||||
/** Whether the right rail should reserve space for agents UI. False in core. */
|
||||
export function useAgentsEnabled(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the agent chat overlay is currently open. Core builds have no chat,
|
||||
* so this always returns false. Proprietary builds bridge to the ChatContext.
|
||||
* Used by {@code RightSidebar} so the fullscreen tool picker can yield to the
|
||||
* chat overlay just like it yields to a selected tool.
|
||||
*/
|
||||
export function useAgentChatOpen(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Inline "Agents" section rendered above the tool list in {@code ToolPicker}. */
|
||||
export function AgentsSection() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon-only agent button rendered in the collapsed (minimised) right rail.
|
||||
* Returns null in core; proprietary renders the Stirling agent shortcut.
|
||||
*/
|
||||
export function AgentsCollapsedButton(_props: { onExpand: () => void }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-rail chat overlay rendered inside {@code ToolPanel}. Covers the panel
|
||||
* (including the search bar) when an agent conversation is active.
|
||||
*/
|
||||
export function AgentsChatOverlay() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents card rendered inside the fullscreen tool picker. Matches the visual
|
||||
* language of the fullscreen category cards (gradient border, title, items).
|
||||
* Returns null in core; proprietary renders the Stirling agent.
|
||||
*/
|
||||
export function AgentsFullscreenSection() {
|
||||
return null;
|
||||
}
|
||||
@@ -75,7 +75,7 @@
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: var(--bg-surface);
|
||||
background: white;
|
||||
transition:
|
||||
box-shadow 0.15s ease,
|
||||
transform 0.2s ease;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, ReactNode } from "react";
|
||||
import { createContext, useContext, ReactNode, useEffect } from "react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { useRainbowTheme } from "@app/hooks/useRainbowTheme";
|
||||
import { mantineTheme } from "@app/theme/mantineTheme";
|
||||
@@ -36,9 +36,20 @@ interface RainbowThemeProviderProps {
|
||||
export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) {
|
||||
const rainbowTheme = useRainbowTheme();
|
||||
|
||||
// Determine the Mantine color scheme
|
||||
// Determine the Mantine color scheme (midnight and rainbow both use Mantine dark)
|
||||
const mantineColorScheme =
|
||||
rainbowTheme.themeMode === "rainbow" ? "dark" : rainbowTheme.themeMode;
|
||||
rainbowTheme.themeMode === "rainbow" ||
|
||||
rainbowTheme.themeMode === "midnight"
|
||||
? "dark"
|
||||
: rainbowTheme.themeMode;
|
||||
|
||||
// Apply data-color-scheme attribute to html element for CSS midnight overrides
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute(
|
||||
"data-color-scheme",
|
||||
rainbowTheme.themeMode,
|
||||
);
|
||||
}, [rainbowTheme.themeMode]);
|
||||
|
||||
return (
|
||||
<RainbowThemeContext.Provider value={rainbowTheme}>
|
||||
|
||||
@@ -49,7 +49,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { config } = useAppConfig();
|
||||
const { toggleTheme, themeMode } = useRainbowThemeContext();
|
||||
const { themeMode } = useRainbowThemeContext();
|
||||
const [fileLimitInput, setFileLimitInput] = useState<number | string>(
|
||||
preferences.autoUnzipFileLimit,
|
||||
);
|
||||
@@ -380,10 +380,10 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
</Text>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
value={themeMode === "rainbow" ? "dark" : themeMode}
|
||||
onChange={(val) => {
|
||||
if ((themeMode === "dark") !== (val === "dark")) toggleTheme();
|
||||
}}
|
||||
value={themeMode === "rainbow" ? "dark" : (themeMode as string)}
|
||||
onChange={(val) =>
|
||||
updatePreference("theme", val as "light" | "dark" | "midnight")
|
||||
}
|
||||
data={[
|
||||
{
|
||||
label: t("settings.general.themeLight", "Light"),
|
||||
@@ -393,6 +393,10 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
label: t("settings.general.themeDark", "Dark"),
|
||||
value: "dark",
|
||||
},
|
||||
{
|
||||
label: t("settings.general.themeMidnight", "Midnight"),
|
||||
value: "midnight",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import { usePreferences } from "@app/contexts/PreferencesContext";
|
||||
import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext";
|
||||
import {
|
||||
AgentsFullscreenSection,
|
||||
useAgentChatOpen,
|
||||
useAgentsEnabled,
|
||||
} from "@app/components/agents/AgentsPanel";
|
||||
import FullscreenToolSurface from "@app/components/tools/FullscreenToolSurface";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import type { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
||||
|
||||
/** Derives whether the fullscreen tool picker is currently expanded. */
|
||||
export function useIsFullscreenExpanded(): boolean {
|
||||
const { toolPanelMode, leftPanelView, readerMode } = useToolWorkflow();
|
||||
const isMobile = useIsMobile();
|
||||
const agentChatOpen = useAgentChatOpen();
|
||||
return (
|
||||
toolPanelMode === "fullscreen" &&
|
||||
leftPanelView === "toolPicker" &&
|
||||
!isMobile &&
|
||||
!readerMode &&
|
||||
!agentChatOpen
|
||||
);
|
||||
}
|
||||
|
||||
interface FullscreenToolPanelProps {
|
||||
geometry: ToolPanelGeometry | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained fullscreen tool picker. Renders null when inactive, and takes
|
||||
* over the right rail (via FullscreenToolSurface) when fullscreen mode is on.
|
||||
* Geometry is computed by the parent (RightSidebar) so its useLayoutEffect runs
|
||||
* after the ref div is committed, ensuring toolPanelRef.current is always set.
|
||||
*/
|
||||
export function FullscreenToolPanel({ geometry }: FullscreenToolPanelProps) {
|
||||
const {
|
||||
toolPanelMode,
|
||||
setToolPanelMode,
|
||||
leftPanelView,
|
||||
readerMode,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filteredTools,
|
||||
toolRegistry,
|
||||
selectedToolKey,
|
||||
handleToolSelect,
|
||||
} = useToolWorkflow();
|
||||
const isMobile = useIsMobile();
|
||||
const agentsEnabled = useAgentsEnabled();
|
||||
const agentChatOpen = useAgentChatOpen();
|
||||
const { setAllButtonsDisabled } = useWorkbenchBar();
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
|
||||
const fullscreenExpanded =
|
||||
toolPanelMode === "fullscreen" &&
|
||||
leftPanelView === "toolPicker" &&
|
||||
!isMobile &&
|
||||
!readerMode &&
|
||||
!agentChatOpen;
|
||||
|
||||
useEffect(() => {
|
||||
setAllButtonsDisabled(fullscreenExpanded);
|
||||
}, [fullscreenExpanded, setAllButtonsDisabled]);
|
||||
|
||||
const matchedTextMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
filteredTools.forEach(({ item: [id], matchedText }) => {
|
||||
if (matchedText) map.set(id, matchedText);
|
||||
});
|
||||
return map;
|
||||
}, [filteredTools]);
|
||||
|
||||
if (!fullscreenExpanded) return null;
|
||||
|
||||
return (
|
||||
<FullscreenToolSurface
|
||||
searchQuery={searchQuery}
|
||||
toolRegistry={toolRegistry}
|
||||
filteredTools={filteredTools}
|
||||
selectedToolKey={selectedToolKey}
|
||||
showDescriptions={preferences.showLegacyToolDescriptions}
|
||||
matchedTextMap={matchedTextMap}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSelect={(id: ToolId) => handleToolSelect(id)}
|
||||
onToggleDescriptions={() =>
|
||||
updatePreference(
|
||||
"showLegacyToolDescriptions",
|
||||
!preferences.showLegacyToolDescriptions,
|
||||
)
|
||||
}
|
||||
onExitFullscreenMode={() => setToolPanelMode("sidebar")}
|
||||
geometry={geometry}
|
||||
agentsSlot={
|
||||
agentsEnabled && !searchQuery ? <AgentsFullscreenSection /> : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ScrollArea, Switch } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
|
||||
@@ -6,8 +7,6 @@ import FullscreenToolList from "@app/components/tools/FullscreenToolList";
|
||||
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import { useFocusTrap } from "@app/hooks/useFocusTrap";
|
||||
import { LogoIcon } from "@app/components/shared/LogoIcon";
|
||||
import { Wordmark } from "@app/components/shared/Wordmark";
|
||||
import "@app/components/tools/ToolPanel.css";
|
||||
import { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
||||
|
||||
@@ -26,6 +25,8 @@ interface FullscreenToolSurfaceProps {
|
||||
onToggleDescriptions: () => void;
|
||||
onExitFullscreenMode: () => void;
|
||||
geometry: ToolPanelGeometry | null;
|
||||
/** Optional agents block rendered above the tool list. */
|
||||
agentsSlot?: React.ReactNode;
|
||||
}
|
||||
|
||||
const FullscreenToolSurface = ({
|
||||
@@ -40,6 +41,7 @@ const FullscreenToolSurface = ({
|
||||
onToggleDescriptions,
|
||||
onExitFullscreenMode: _onExitFullscreenMode,
|
||||
geometry,
|
||||
agentsSlot,
|
||||
}: FullscreenToolSurfaceProps) => {
|
||||
const { t } = useTranslation();
|
||||
const surfaceRef = useRef<HTMLDivElement>(null);
|
||||
@@ -47,18 +49,16 @@ const FullscreenToolSurface = ({
|
||||
// Enable focus trap when surface is active
|
||||
useFocusTrap(surfaceRef, true);
|
||||
|
||||
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
|
||||
if (!geometry) return null;
|
||||
|
||||
const style = geometry
|
||||
? {
|
||||
left: `${geometry.left}px`,
|
||||
top: `${geometry.top}px`,
|
||||
width: `${geometry.width}px`,
|
||||
height: `${geometry.height}px`,
|
||||
}
|
||||
: undefined;
|
||||
const style = {
|
||||
left: `${geometry.left}px`,
|
||||
top: `${geometry.top}px`,
|
||||
width: `${geometry.width}px`,
|
||||
height: `${geometry.height}px`,
|
||||
};
|
||||
|
||||
return (
|
||||
const surface = (
|
||||
<div
|
||||
className="tool-panel__fullscreen-surface"
|
||||
style={style}
|
||||
@@ -70,16 +70,6 @@ const FullscreenToolSurface = ({
|
||||
data-tour="tool-panel"
|
||||
>
|
||||
<div ref={surfaceRef} className="tool-panel__fullscreen-surface-inner">
|
||||
<header className="tool-panel__fullscreen-header">
|
||||
<div className="tool-panel__fullscreen-brand">
|
||||
<LogoIcon className="tool-panel__fullscreen-brand-icon" />
|
||||
<Wordmark
|
||||
alt={brandAltText}
|
||||
className="tool-panel__fullscreen-brand-text"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="tool-panel__fullscreen-controls">
|
||||
<ToolSearch
|
||||
value={searchQuery}
|
||||
@@ -102,6 +92,9 @@ const FullscreenToolSurface = ({
|
||||
className="tool-panel__fullscreen-scroll"
|
||||
offsetScrollbars
|
||||
>
|
||||
{agentsSlot && (
|
||||
<div className="tool-panel__fullscreen-agents">{agentsSlot}</div>
|
||||
)}
|
||||
<FullscreenToolList
|
||||
filteredTools={filteredTools}
|
||||
searchQuery={searchQuery}
|
||||
@@ -115,6 +108,9 @@ const FullscreenToolSurface = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (typeof document === "undefined") return surface;
|
||||
return createPortal(surface, document.body);
|
||||
};
|
||||
|
||||
export default FullscreenToolSurface;
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useSidebarContext } from "@app/contexts/SidebarContext";
|
||||
import rainbowStyles from "@app/styles/rainbow.module.css";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import ToolPanel from "@app/components/tools/ToolPanel";
|
||||
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
|
||||
import {
|
||||
AgentsChatOverlay,
|
||||
AgentsCollapsedButton,
|
||||
AgentsSection,
|
||||
useAgentsEnabled,
|
||||
} from "@app/components/agents/AgentsPanel";
|
||||
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
|
||||
import { useToolSections } from "@app/hooks/useToolSections";
|
||||
import type { SubcategoryGroup } from "@app/hooks/useToolSections";
|
||||
import { ToolIcon } from "@app/components/shared/ToolIcon";
|
||||
import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
|
||||
import { withViewTransition } from "@app/utils/viewTransition";
|
||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import type { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
||||
import {
|
||||
FullscreenToolPanel,
|
||||
useIsFullscreenExpanded,
|
||||
} from "@app/components/tools/FullscreenToolPanel";
|
||||
import { useToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
||||
import "@app/components/tools/ToolPanel.css";
|
||||
|
||||
/**
|
||||
* Right-side rail wrapping the tool panel.
|
||||
*
|
||||
* Owns the rail-level concerns: collapse/expand chrome, the AGENTS top label,
|
||||
* the collapsed strip (agent button + favourite/recommended icon shortcuts),
|
||||
* and the agents chat overlay. Fullscreen takeover lives in FullscreenToolPanel.
|
||||
*/
|
||||
export default function RightSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { toolPanelRef, quickAccessRef } = sidebarRefs;
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const {
|
||||
leftPanelView,
|
||||
isPanelVisible,
|
||||
searchQuery,
|
||||
filteredTools,
|
||||
toolRegistry,
|
||||
setSearchQuery,
|
||||
selectedToolKey,
|
||||
handleToolSelect,
|
||||
handleBackToTools,
|
||||
setLeftPanelView,
|
||||
setReaderMode,
|
||||
setSidebarsVisible,
|
||||
sidebarsVisible,
|
||||
readerMode,
|
||||
favoriteTools,
|
||||
} = useToolWorkflow();
|
||||
|
||||
const agentsEnabled = useAgentsEnabled();
|
||||
const fullscreenExpanded = useIsFullscreenExpanded();
|
||||
const fullscreenGeometry = useToolPanelGeometry({
|
||||
enabled: fullscreenExpanded,
|
||||
toolPanelRef,
|
||||
quickAccessRef,
|
||||
});
|
||||
|
||||
const handleExpand = () => {
|
||||
withViewTransition(() => {
|
||||
if (readerMode) setReaderMode(false);
|
||||
if (leftPanelView === "hidden") setLeftPanelView("toolPicker");
|
||||
if (!sidebarsVisible) setSidebarsVisible(true);
|
||||
});
|
||||
};
|
||||
|
||||
const handleCollapse = () => {
|
||||
withViewTransition(() => setLeftPanelView("hidden"));
|
||||
};
|
||||
|
||||
const [allToolsView, setAllToolsView] = useState(false);
|
||||
|
||||
const handleShowAllTools = () => {
|
||||
withViewTransition(() => setAllToolsView(true));
|
||||
};
|
||||
|
||||
const handleBackToDefault = () => {
|
||||
withViewTransition(() => {
|
||||
setAllToolsView(false);
|
||||
setSearchQuery("");
|
||||
});
|
||||
};
|
||||
|
||||
// The header shows [back] [search] when we have somewhere to go back to —
|
||||
// i.e. the user is in a specific tool, or already in the all-tools/search view.
|
||||
const inToolView = leftPanelView !== "toolPicker";
|
||||
// Show X (close) button only when there's somewhere to go back to.
|
||||
const showCloseButton = inToolView || allToolsView;
|
||||
// Show search input whenever there's a close button, or when agents are off and
|
||||
// we're in the default tool-picker view (search filters the full list inline).
|
||||
const showHeaderSearch =
|
||||
showCloseButton || (!agentsEnabled && leftPanelView === "toolPicker");
|
||||
|
||||
const handleHeaderBack = () => {
|
||||
if (inToolView) {
|
||||
withViewTransition(() => handleBackToTools());
|
||||
} else {
|
||||
handleBackToDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handleToolSelectWithTransition = (id: ToolId) => {
|
||||
withViewTransition(() => handleToolSelect(id));
|
||||
};
|
||||
|
||||
// Typing in the header search while inside a tool exits the tool and lifts the
|
||||
// panel into the all-tools view so the user immediately sees search results.
|
||||
const handleHeaderSearchChange = (value: string) => {
|
||||
if (inToolView) {
|
||||
withViewTransition(() => {
|
||||
handleBackToTools();
|
||||
setAllToolsView(true);
|
||||
setSearchQuery(value);
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSearchQuery(value);
|
||||
};
|
||||
|
||||
const activeTool: ToolRegistryEntry | null =
|
||||
inToolView && selectedToolKey
|
||||
? (toolRegistry[selectedToolKey as ToolId] ?? null)
|
||||
: null;
|
||||
|
||||
// Agents header + section is hidden when:
|
||||
// - the AI engine is off, or
|
||||
// - the user is in the all-tools view, or
|
||||
// - a specific tool is being rendered (leftPanelView ≠ "toolPicker").
|
||||
const showAgents =
|
||||
agentsEnabled && !allToolsView && leftPanelView === "toolPicker";
|
||||
|
||||
const computedWidth = () => {
|
||||
if (isMobile) return "100%";
|
||||
if (!isPanelVisible) return "3.5rem";
|
||||
return "18.5rem";
|
||||
};
|
||||
|
||||
// Collapsed rail: show favourites + recommended tools as icons.
|
||||
const favoriteToolItems = useFavoriteToolItems(favoriteTools, toolRegistry);
|
||||
const { sections: collapsedSections } = useToolSections(filteredTools);
|
||||
const collapsedQuickSection = useMemo(
|
||||
() => collapsedSections.find((s) => s.key === "quick"),
|
||||
[collapsedSections],
|
||||
);
|
||||
const collapsedRecommendedItems = useMemo(() => {
|
||||
if (!collapsedQuickSection) return [];
|
||||
const items: Array<{ id: ToolId; tool: ToolRegistryEntry }> = [];
|
||||
collapsedQuickSection.subcategories.forEach((sc: SubcategoryGroup) =>
|
||||
sc.tools.forEach((entry) =>
|
||||
items.push({ id: entry.id as ToolId, tool: entry.tool }),
|
||||
),
|
||||
);
|
||||
return items;
|
||||
}, [collapsedQuickSection]);
|
||||
const collapsedRailItems = useMemo(() => {
|
||||
const map = new Map<ToolId, ToolRegistryEntry>();
|
||||
favoriteToolItems.forEach(({ id, tool }) => map.set(id, tool));
|
||||
collapsedRecommendedItems.forEach(({ id, tool }) => {
|
||||
if (!map.has(id)) map.set(id, tool);
|
||||
});
|
||||
return Array.from(map, ([id, tool]) => ({ id, tool }));
|
||||
}, [favoriteToolItems, collapsedRecommendedItems]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={toolPanelRef}
|
||||
data-sidebar="tool-panel"
|
||||
data-tour={fullscreenExpanded ? undefined : "tool-panel"}
|
||||
className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-l border-[var(--border-subtle)] transition-all duration-300 ease-out ${
|
||||
isRainbowMode ? rainbowStyles.rainbowPaper : ""
|
||||
} ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`}
|
||||
style={{
|
||||
width: computedWidth(),
|
||||
padding: "0",
|
||||
}}
|
||||
>
|
||||
{!fullscreenExpanded && !isPanelVisible && !isMobile && (
|
||||
<div className="tool-panel__collapsed-strip">
|
||||
<div className="tool-panel__collapsed-top">
|
||||
<ActionIcon
|
||||
variant="outline"
|
||||
color="gray.4"
|
||||
radius="xl"
|
||||
size="md"
|
||||
className="tool-panel__expand-btn"
|
||||
onClick={handleExpand}
|
||||
aria-label={t("toolPanel.expand", "Expand panel")}
|
||||
>
|
||||
<ChevronLeftIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
<AgentsCollapsedButton onExpand={handleExpand} />
|
||||
</div>
|
||||
<div className="tool-panel__collapsed-divider" />
|
||||
<div className="tool-panel__collapsed-tools">
|
||||
{collapsedRailItems.map(({ id, tool }) => (
|
||||
<AppTooltip
|
||||
key={id}
|
||||
content={tool.name}
|
||||
position="left"
|
||||
arrow
|
||||
delay={300}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="tool-panel__collapsed-tool-btn"
|
||||
data-selected={selectedToolKey === id}
|
||||
onClick={() => {
|
||||
handleExpand();
|
||||
handleToolSelectWithTransition(id);
|
||||
}}
|
||||
aria-label={tool.name}
|
||||
>
|
||||
<ToolIcon icon={tool.icon} marginRight="0" />
|
||||
</button>
|
||||
</AppTooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fullscreenExpanded && isPanelVisible && (
|
||||
<div
|
||||
/* Fixed width matches the expanded panel width so the inner content is
|
||||
laid out at its final size from the moment it mounts. The outer
|
||||
.tool-panel clips it (overflow-hidden) while it animates from the
|
||||
collapsed 3.5rem width — text/icons stay put and just come into view
|
||||
instead of jiggling as space becomes available. */
|
||||
style={{
|
||||
opacity: 1,
|
||||
transition: "opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
|
||||
height: "100%",
|
||||
width: isMobile ? "100%" : "18.5rem",
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div className="tool-panel__compact-header">
|
||||
{activeTool ? (
|
||||
<div
|
||||
className="tool-panel__active-tool-pill"
|
||||
aria-label={activeTool.name}
|
||||
>
|
||||
<span className="tool-panel__active-tool-pill-icon">
|
||||
<ToolIcon
|
||||
icon={activeTool.icon}
|
||||
marginRight="0"
|
||||
color="var(--mantine-color-blue-filled)"
|
||||
/>
|
||||
</span>
|
||||
<span className="tool-panel__active-tool-pill-label">
|
||||
{activeTool.name}
|
||||
</span>
|
||||
</div>
|
||||
) : showHeaderSearch ? (
|
||||
<div className="tool-panel__compact-header-search">
|
||||
<ToolSearch
|
||||
value={searchQuery}
|
||||
onChange={handleHeaderSearchChange}
|
||||
toolRegistry={toolRegistry}
|
||||
mode="filter"
|
||||
autoFocus={allToolsView && !inToolView}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
showAgents && (
|
||||
<span className="tool-panel__section-label">
|
||||
{t("agents.section_title", "Agents")}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{showCloseButton ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="xl"
|
||||
size="md"
|
||||
onClick={handleHeaderBack}
|
||||
aria-label={
|
||||
inToolView
|
||||
? t("toolPanel.backToAllTools", "Back to all tools")
|
||||
: t("toolPanel.goBack", "Go back")
|
||||
}
|
||||
className="tool-panel__expand-btn"
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<ActionIcon
|
||||
variant="outline"
|
||||
radius="xl"
|
||||
size="md"
|
||||
onClick={handleCollapse}
|
||||
aria-label={t("toolPanel.collapse", "Collapse panel")}
|
||||
className="tool-panel__expand-btn"
|
||||
>
|
||||
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAgents && <AgentsSection />}
|
||||
|
||||
<ToolPanel
|
||||
allToolsView={allToolsView}
|
||||
onShowAllTools={handleShowAllTools}
|
||||
onToolSelect={handleToolSelectWithTransition}
|
||||
compact={agentsEnabled && !allToolsView}
|
||||
/>
|
||||
<AgentsChatOverlay />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FullscreenToolPanel geometry={fullscreenGeometry} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -129,17 +129,70 @@
|
||||
transition:
|
||||
width 0.3s ease,
|
||||
max-width 0.3s ease;
|
||||
view-transition-name: tool-rail;
|
||||
}
|
||||
|
||||
.tool-panel__collapsed-strip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
padding-top: 10px;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tool-panel__collapsed-top {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 0 10px;
|
||||
}
|
||||
|
||||
.tool-panel__collapsed-divider {
|
||||
height: 1px;
|
||||
background: var(--border-subtle);
|
||||
margin: 0 0.5rem 8px;
|
||||
}
|
||||
|
||||
.tool-panel__collapsed-tools {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 0 12px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tool-panel__collapsed-tool-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
color: var(--tools-text-and-icon-color);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 120ms ease-out,
|
||||
border-color 120ms ease-out;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-panel__collapsed-tool-btn:hover {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.tool-panel__collapsed-tool-btn[data-selected="true"] {
|
||||
background: var(--mantine-color-default-hover);
|
||||
border-color: var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.tool-panel__expand-btn {
|
||||
@@ -174,6 +227,11 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-panel__back-bar {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-subtle) !important;
|
||||
}
|
||||
|
||||
.tool-panel--fullscreen-active {
|
||||
overflow: visible !important;
|
||||
}
|
||||
@@ -191,6 +249,133 @@
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.tool-panel__compact-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 52px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tool-panel__compact-header .tool-panel__expand-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.tool-panel__compact-header-search {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tool-panel__compact-header-search .search-input-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tool-panel__active-tool-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0.35rem 0.75rem 0.35rem 0.4rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 9999px;
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.tool-panel__active-tool-pill-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--mantine-color-blue-light);
|
||||
color: var(--mantine-color-blue-filled);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .tool-panel__active-tool-pill {
|
||||
background: transparent;
|
||||
border-color: var(--border-subtle);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .tool-panel__active-tool-pill-icon {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-filled) 18%,
|
||||
transparent
|
||||
);
|
||||
color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled));
|
||||
}
|
||||
|
||||
/* The inner .tool-button-icon wrapper carries its own transform/margin; reset
|
||||
them so the icon glyph sits dead-centre in the circular background. */
|
||||
.tool-panel__active-tool-pill-icon .tool-button-icon {
|
||||
margin: 0 !important;
|
||||
transform: none !important;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tool-panel__active-tool-pill-icon svg {
|
||||
font-size: 1rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.tool-panel__active-tool-pill-label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
::view-transition-old(tool-rail) {
|
||||
animation: vt-rail-fade-out 180ms ease-out forwards;
|
||||
}
|
||||
|
||||
::view-transition-new(tool-rail) {
|
||||
animation: vt-rail-fade-in 220ms ease-in forwards;
|
||||
}
|
||||
|
||||
@keyframes vt-rail-fade-out {
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes vt-rail-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-old(tool-rail),
|
||||
::view-transition-new(tool-rail) {
|
||||
animation-duration: 1ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-panel__section-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tool-panel__mode-toggle {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
@@ -652,6 +837,33 @@
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Agents card lives in its own column above the tool grid. It uses the same
|
||||
.tool-panel__fullscreen-group base styles but with a colourful gradient
|
||||
border so it stands out as a distinct surface (and doesn't blend into the
|
||||
neighbouring category cards). */
|
||||
.tool-panel__fullscreen-agents {
|
||||
padding: 1.5rem 1.75rem 0;
|
||||
}
|
||||
|
||||
.tool-panel__fullscreen-group--agents {
|
||||
position: relative;
|
||||
background:
|
||||
linear-gradient(var(--fullscreen-bg-group), var(--fullscreen-bg-group))
|
||||
padding-box,
|
||||
linear-gradient(
|
||||
135deg,
|
||||
var(--mantine-color-blue-6) 0%,
|
||||
var(--mantine-color-violet-6) 45%,
|
||||
var(--mantine-color-pink-6) 100%
|
||||
)
|
||||
border-box;
|
||||
border: 1.5px solid transparent;
|
||||
}
|
||||
|
||||
.tool-panel__fullscreen-section-icon--agents {
|
||||
color: var(--mantine-color-blue-6);
|
||||
}
|
||||
|
||||
@keyframes tool-panel-fullscreen-slide-in {
|
||||
from {
|
||||
transform: translateX(6%) scaleX(0.85);
|
||||
|
||||
@@ -1,303 +1,85 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
||||
import {
|
||||
useToolWorkflow,
|
||||
useToolWorkflowActions,
|
||||
} from "@app/contexts/ToolWorkflowContext";
|
||||
import { usePreferences } from "@app/contexts/PreferencesContext";
|
||||
import { ScrollArea } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import ToolPicker from "@app/components/tools/ToolPicker";
|
||||
import SearchResults from "@app/components/tools/SearchResults";
|
||||
import ToolRenderer from "@app/components/tools/ToolRenderer";
|
||||
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
|
||||
import { useSidebarContext } from "@app/contexts/SidebarContext";
|
||||
import rainbowStyles from "@app/styles/rainbow.module.css";
|
||||
import { ActionIcon, Button, ScrollArea } from "@mantine/core";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FullscreenToolSurface from "@app/components/tools/FullscreenToolSurface";
|
||||
import { ToolPanelViewerBar } from "@app/components/tools/ToolPanelViewerBar";
|
||||
import { useToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
||||
import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext";
|
||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import "@app/components/tools/ToolPanel.css";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
|
||||
// No props needed - component uses context
|
||||
interface ToolPanelProps {
|
||||
/** Whether to expand into the full categorised tools view (with search). */
|
||||
allToolsView: boolean;
|
||||
/** Trigger to enter the all-tools view (from the "View all tools" button). */
|
||||
onShowAllTools: () => void;
|
||||
/**
|
||||
* Tool-selection handler injected by {@code RightSidebar} so the click can
|
||||
* be wrapped in a View Transition (the tool card morphs into the header
|
||||
* pill). Falls back to the workflow context's handler when not provided.
|
||||
*/
|
||||
onToolSelect?: (id: ToolId) => void;
|
||||
/** Whether to render the compact (favourites + recommended only) view. */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export default function ToolPanel() {
|
||||
/** Tool list and renderer for the right rail; rail chrome lives in RightSidebar. */
|
||||
export default function ToolPanel({
|
||||
allToolsView,
|
||||
onShowAllTools,
|
||||
onToolSelect,
|
||||
compact: compactProp,
|
||||
}: ToolPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { toolPanelRef, quickAccessRef } = sidebarRefs;
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const {
|
||||
leftPanelView,
|
||||
isPanelVisible,
|
||||
searchQuery,
|
||||
filteredTools,
|
||||
toolRegistry,
|
||||
setSearchQuery,
|
||||
selectedToolKey,
|
||||
toolPanelMode,
|
||||
sidebarsVisible,
|
||||
readerMode,
|
||||
} = useToolWorkflow();
|
||||
const {
|
||||
handleToolSelect,
|
||||
handleBackToTools,
|
||||
setPreviewFile,
|
||||
setToolPanelMode,
|
||||
setLeftPanelView,
|
||||
setReaderMode,
|
||||
setSidebarsVisible,
|
||||
} = useToolWorkflowActions();
|
||||
|
||||
const { setAllButtonsDisabled } = useWorkbenchBar();
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
|
||||
const isFullscreenMode = toolPanelMode === "fullscreen";
|
||||
const toolPickerVisible = !readerMode;
|
||||
const fullscreenExpanded =
|
||||
isFullscreenMode &&
|
||||
leftPanelView === "toolPicker" &&
|
||||
!isMobile &&
|
||||
toolPickerVisible;
|
||||
|
||||
// Disable workbench bar buttons when fullscreen mode is active
|
||||
useEffect(() => {
|
||||
setAllButtonsDisabled(fullscreenExpanded);
|
||||
}, [fullscreenExpanded, setAllButtonsDisabled]);
|
||||
|
||||
const fullscreenGeometry = useToolPanelGeometry({
|
||||
enabled: fullscreenExpanded,
|
||||
toolPanelRef,
|
||||
quickAccessRef,
|
||||
});
|
||||
|
||||
const handleExpand = () => {
|
||||
if (readerMode) setReaderMode(false);
|
||||
if (leftPanelView === "hidden") setLeftPanelView("toolPicker");
|
||||
if (!sidebarsVisible) setSidebarsVisible(true);
|
||||
};
|
||||
|
||||
const handleCollapse = () => {
|
||||
setLeftPanelView("hidden");
|
||||
};
|
||||
|
||||
const [focusSearch, setFocusSearch] = useState(false);
|
||||
const focusSearchOnNextOpen = useRef(false);
|
||||
|
||||
const handleExpandAndSearch = () => {
|
||||
focusSearchOnNextOpen.current = true;
|
||||
handleExpand();
|
||||
};
|
||||
|
||||
// Once the panel becomes visible, consume the focus-search request
|
||||
useEffect(() => {
|
||||
if (isPanelVisible && focusSearchOnNextOpen.current) {
|
||||
focusSearchOnNextOpen.current = false;
|
||||
setFocusSearch(true);
|
||||
// Reset after one render so autoFocus doesn't re-fire on subsequent renders
|
||||
const id = setTimeout(() => setFocusSearch(false), 100);
|
||||
return () => clearTimeout(id);
|
||||
}
|
||||
}, [isPanelVisible]);
|
||||
|
||||
const computedWidth = () => {
|
||||
if (isMobile) {
|
||||
return "100%";
|
||||
}
|
||||
|
||||
if (!isPanelVisible) {
|
||||
return "3.5rem";
|
||||
}
|
||||
|
||||
return "18.5rem";
|
||||
};
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => handleToolSelect(id as ToolId),
|
||||
[handleToolSelect],
|
||||
);
|
||||
|
||||
const matchedTextMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
filteredTools.forEach(({ item: [id], matchedText }) => {
|
||||
if (matchedText) {
|
||||
map.set(id, matchedText);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [filteredTools]);
|
||||
} = useToolWorkflow();
|
||||
const selectTool = onToolSelect ?? handleToolSelect;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={toolPanelRef}
|
||||
data-sidebar="tool-panel"
|
||||
data-tour={fullscreenExpanded ? undefined : "tool-panel"}
|
||||
className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-l border-[var(--border-subtle)] transition-all duration-300 ease-out ${
|
||||
isRainbowMode ? rainbowStyles.rainbowPaper : ""
|
||||
} ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`}
|
||||
style={{
|
||||
width: computedWidth(),
|
||||
padding: "0",
|
||||
}}
|
||||
>
|
||||
{!fullscreenExpanded && !isPanelVisible && !isMobile && (
|
||||
<div className="tool-panel__collapsed-strip">
|
||||
<ActionIcon
|
||||
variant="outline"
|
||||
color="gray.4"
|
||||
radius="xl"
|
||||
size="md"
|
||||
className="tool-panel__expand-btn"
|
||||
onClick={handleExpand}
|
||||
aria-label={t("toolPanel.expand", "Expand panel")}
|
||||
>
|
||||
<ChevronLeftIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
className="tool-panel__collapsed-search-btn"
|
||||
onClick={handleExpandAndSearch}
|
||||
aria-label={t("toolPanel.search", "Search tools")}
|
||||
style={{ marginTop: "8px" }}
|
||||
>
|
||||
<SearchIcon sx={{ fontSize: "1.25rem" }} />
|
||||
</ActionIcon>
|
||||
<>
|
||||
{/* Viewer mode tools — annotate, redact, form fill */}
|
||||
<ToolPanelViewerBar />
|
||||
|
||||
{allToolsView && searchQuery.trim().length > 0 ? (
|
||||
<div className="flex-1 flex flex-col overflow-y-auto">
|
||||
<SearchResults
|
||||
filteredTools={filteredTools}
|
||||
onSelect={(id) => selectTool(id as ToolId)}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fullscreenExpanded && isPanelVisible && (
|
||||
<div
|
||||
style={{
|
||||
opacity: 1,
|
||||
transition: "opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* Viewer mode tools — annotate, redact, form fill */}
|
||||
<ToolPanelViewerBar />
|
||||
|
||||
<div
|
||||
className="tool-panel__search-row"
|
||||
style={{
|
||||
backgroundColor: "transparent",
|
||||
borderBottom: "1px solid var(--border-subtle)",
|
||||
}}
|
||||
>
|
||||
<ToolSearch
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
toolRegistry={toolRegistry}
|
||||
mode="filter"
|
||||
autoFocus={focusSearch}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="outline"
|
||||
radius="xl"
|
||||
size="md"
|
||||
onClick={handleCollapse}
|
||||
aria-label={t("toolPanel.collapse", "Collapse panel")}
|
||||
className="tool-panel__expand-btn"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
|
||||
{searchQuery.trim().length > 0 ? (
|
||||
<div className="flex-1 flex flex-col overflow-y-auto">
|
||||
<SearchResults
|
||||
filteredTools={filteredTools}
|
||||
onSelect={handleSelect}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
) : leftPanelView === "toolPicker" ? (
|
||||
<div className="flex-1 flex flex-col overflow-auto">
|
||||
<ToolPicker
|
||||
) : leftPanelView === "toolPicker" ? (
|
||||
<div className="flex-1 flex flex-col overflow-auto">
|
||||
<ToolPicker
|
||||
selectedToolKey={selectedToolKey}
|
||||
onSelect={(id) => selectTool(id as ToolId)}
|
||||
filteredTools={filteredTools}
|
||||
isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)}
|
||||
compact={compactProp ?? !allToolsView}
|
||||
onShowAllTools={onShowAllTools}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ScrollArea h="100%">
|
||||
{selectedToolKey ? (
|
||||
<ToolRenderer
|
||||
selectedToolKey={selectedToolKey}
|
||||
onSelect={handleSelect}
|
||||
filteredTools={filteredTools}
|
||||
isSearching={Boolean(
|
||||
searchQuery && searchQuery.trim().length > 0,
|
||||
)}
|
||||
onPreviewFile={setPreviewFile}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div
|
||||
style={{
|
||||
borderBottom: "1px solid var(--border-subtle)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
size="sm"
|
||||
fullWidth
|
||||
radius={0}
|
||||
leftSection={<ArrowBackIcon sx={{ fontSize: "0.9rem" }} />}
|
||||
onClick={handleBackToTools}
|
||||
aria-label={t("toolPanel.backToTools", "Back to tools")}
|
||||
styles={{ root: { justifyContent: "flex-start" } }}
|
||||
>
|
||||
{t("toolPanel.backToTools", "Back to tools")}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="tool-panel__placeholder">
|
||||
{t("toolPanel.placeholder", "Choose a tool to get started")}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ScrollArea h="100%">
|
||||
{selectedToolKey ? (
|
||||
<ToolRenderer
|
||||
selectedToolKey={selectedToolKey}
|
||||
onPreviewFile={setPreviewFile}
|
||||
/>
|
||||
) : (
|
||||
<div className="tool-panel__placeholder">
|
||||
{t(
|
||||
"toolPanel.placeholder",
|
||||
"Choose a tool to get started",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fullscreenExpanded && (
|
||||
<FullscreenToolSurface
|
||||
searchQuery={searchQuery}
|
||||
toolRegistry={toolRegistry}
|
||||
filteredTools={filteredTools}
|
||||
selectedToolKey={selectedToolKey}
|
||||
showDescriptions={preferences.showLegacyToolDescriptions}
|
||||
matchedTextMap={matchedTextMap}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSelect={(id: ToolId) => handleToolSelect(id)}
|
||||
onToggleDescriptions={() =>
|
||||
updatePreference(
|
||||
"showLegacyToolDescriptions",
|
||||
!preferences.showLegacyToolDescriptions,
|
||||
)
|
||||
}
|
||||
onExitFullscreenMode={() => setToolPanelMode("sidebar")}
|
||||
geometry={fullscreenGeometry}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { memo, useMemo, useRef } from "react";
|
||||
import { Box, Stack } from "@mantine/core";
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import { Box, Button, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
||||
import "@app/components/tools/toolPicker/ToolPicker.css";
|
||||
@@ -22,6 +22,10 @@ interface ToolPickerProps {
|
||||
matchedText?: string;
|
||||
}>;
|
||||
isSearching?: boolean;
|
||||
/** Compact "resting" view: favourites + recommended only, with a button to expand. */
|
||||
compact?: boolean;
|
||||
/** Called when the user clicks "View all tools" in compact mode. */
|
||||
onShowAllTools?: () => void;
|
||||
}
|
||||
|
||||
const EMPTY_FILTERED_TOOLS: ToolPickerProps["filteredTools"] = [];
|
||||
@@ -57,6 +61,8 @@ const ToolPicker = ({
|
||||
onSelect,
|
||||
filteredTools,
|
||||
isSearching = false,
|
||||
compact = false,
|
||||
onShowAllTools,
|
||||
}: ToolPickerProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -118,9 +124,60 @@ const ToolPicker = ({
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
) : compact ? (
|
||||
/* Resting state: flat list of pinned + recommended only. */
|
||||
<Box className="tool-picker__compact">
|
||||
<div style={HEADER_TEXT_STYLE}>
|
||||
{t("toolPanel.toolsHeader", "Tools")}
|
||||
</div>
|
||||
{favoriteToolItems.length === 0 && recommendedItems.length === 0 ? (
|
||||
<NoToolsFound />
|
||||
) : (
|
||||
<div className="tool-picker__compact-list">
|
||||
{favoriteToolItems.map(({ id, tool }) => (
|
||||
<ToolButton
|
||||
key={`fav-${id}`}
|
||||
id={id}
|
||||
tool={tool}
|
||||
isSelected={selectedToolKey === id}
|
||||
onSelect={onSelect}
|
||||
hasStars
|
||||
showDescription
|
||||
/>
|
||||
))}
|
||||
{recommendedItems
|
||||
.filter(
|
||||
({ id }) => !favoriteToolItems.some((fav) => fav.id === id),
|
||||
)
|
||||
.map(({ id, tool }) => (
|
||||
<ToolButton
|
||||
key={`rec-${id}`}
|
||||
id={id as ToolId}
|
||||
tool={tool}
|
||||
isSelected={selectedToolKey === id}
|
||||
onSelect={onSelect}
|
||||
hasStars
|
||||
showDescription
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{onShowAllTools && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
fullWidth
|
||||
onClick={onShowAllTools}
|
||||
className="tool-picker__view-all"
|
||||
aria-label={t("toolPanel.viewAllTools", "View all tools")}
|
||||
>
|
||||
{t("toolPanel.viewAllTools", "View all tools")}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{/* Flat list: favorites and recommended first, then all subcategories */}
|
||||
{/* All-tools view: favourites + recommended + all subcategories. */}
|
||||
<Stack p="sm" gap="xs">
|
||||
{favoriteToolItems.length > 0 && (
|
||||
<Box w="100%">
|
||||
@@ -182,7 +239,6 @@ const ToolPicker = ({
|
||||
|
||||
{!quickSection && !allSection && <NoToolsFound />}
|
||||
|
||||
{/* bottom spacer to allow scrolling past the last row */}
|
||||
<div aria-hidden style={{ height: 200 }} />
|
||||
</>
|
||||
)}
|
||||
@@ -192,4 +248,4 @@ const ToolPicker = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ToolPicker);
|
||||
export default ToolPicker;
|
||||
|
||||
@@ -32,6 +32,7 @@ interface ToolButtonProps {
|
||||
disableNavigation?: boolean;
|
||||
matchedSynonym?: string;
|
||||
hasStars?: boolean;
|
||||
showDescription?: boolean;
|
||||
/** Called when an unavailable tool is clicked; if provided, overrides the default no-op */
|
||||
onUnavailableClick?: () => void;
|
||||
}
|
||||
@@ -44,6 +45,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
disableNavigation = false,
|
||||
matchedSynonym,
|
||||
hasStars = false,
|
||||
showDescription = false,
|
||||
onUnavailableClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -183,6 +185,14 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
)}
|
||||
{usesCloud && !visuallyUnavailable && <CloudBadge />}
|
||||
</div>
|
||||
{showDescription && tool.description && (
|
||||
<span
|
||||
className="tool-button__description"
|
||||
style={{ opacity: visuallyUnavailable ? 0.25 : 1 }}
|
||||
>
|
||||
{tool.description}
|
||||
</span>
|
||||
)}
|
||||
{matchedSynonym && (
|
||||
<span
|
||||
style={{
|
||||
@@ -205,8 +215,8 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
handleUnlessSpecialClick(e, () => handleClick(id));
|
||||
};
|
||||
|
||||
const selectedStyles = isSelected
|
||||
? { backgroundColor: "#EAEAEA", color: "var(--tools-text-and-icon-color)" }
|
||||
const selectedBg = isSelected
|
||||
? { backgroundColor: "var(--tool-button-selected-bg)" }
|
||||
: {};
|
||||
|
||||
const buttonElement = navProps ? (
|
||||
@@ -227,7 +237,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
borderRadius: 0,
|
||||
color: "var(--tools-text-and-icon-color)",
|
||||
overflow: "visible",
|
||||
...selectedStyles,
|
||||
...selectedBg,
|
||||
},
|
||||
label: { overflow: "visible" },
|
||||
}}
|
||||
@@ -254,7 +264,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
borderRadius: 0,
|
||||
color: "var(--tools-text-and-icon-color)",
|
||||
overflow: "visible",
|
||||
...selectedStyles,
|
||||
...selectedBg,
|
||||
},
|
||||
label: { overflow: "visible" },
|
||||
}}
|
||||
@@ -279,7 +289,6 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
color: "var(--tools-text-and-icon-color)",
|
||||
cursor: visuallyUnavailable ? "not-allowed" : undefined,
|
||||
overflow: "visible",
|
||||
...selectedStyles,
|
||||
},
|
||||
label: { overflow: "visible" },
|
||||
}}
|
||||
|
||||
@@ -55,6 +55,15 @@
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* Selected tool highlight — theme-aware via CSS variable */
|
||||
:root {
|
||||
--tool-button-selected-bg: var(--mantine-color-gray-2);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] {
|
||||
--tool-button-selected-bg: var(--mantine-color-dark-4);
|
||||
}
|
||||
|
||||
/* Compact tool buttons */
|
||||
.tool-button {
|
||||
font-size: 0.875rem;
|
||||
@@ -96,3 +105,37 @@
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-button__description {
|
||||
font-size: 0.75rem;
|
||||
color: var(--mantine-color-dimmed);
|
||||
line-height: 1.35;
|
||||
margin-top: 0.15rem;
|
||||
white-space: normal;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Resting / compact tool list — taller rows, room for description. */
|
||||
.tool-picker__compact {
|
||||
padding: 0.5rem var(--mantine-spacing-sm) var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.tool-picker__compact .tool-button {
|
||||
padding-top: 0.65rem;
|
||||
padding-bottom: 0.65rem;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.tool-picker__compact .tool-button .mantine-Button-label {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tool-picker__compact-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.tool-picker__view-all {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -17,17 +17,13 @@ function CopyButton({ text }: { text: string }) {
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
background: copied
|
||||
? "var(--mantine-color-green-0)"
|
||||
: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
background: copied ? "var(--color-green-100)" : "var(--bg-muted)",
|
||||
border: "1px solid var(--border-default)",
|
||||
borderRadius: 3,
|
||||
cursor: "pointer",
|
||||
fontSize: "0.7em",
|
||||
padding: "2px 8px",
|
||||
color: copied
|
||||
? "var(--mantine-color-green-7)"
|
||||
: "var(--mantine-color-gray-7)",
|
||||
color: copied ? "var(--color-green-700)" : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{copied ? "✓ Copied" : "Copy"}
|
||||
@@ -36,6 +32,183 @@ function CopyButton({ text }: { text: string }) {
|
||||
}
|
||||
|
||||
const components: Components = {
|
||||
h1: ({ children }) => (
|
||||
<h1
|
||||
style={{
|
||||
color: "var(--text-primary)",
|
||||
borderBottom: "1px solid var(--border-default)",
|
||||
paddingBottom: "0.3em",
|
||||
marginTop: "1.2em",
|
||||
marginBottom: "0.6em",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2
|
||||
style={{
|
||||
color: "var(--text-primary)",
|
||||
borderBottom: "1px solid var(--border-subtle)",
|
||||
paddingBottom: "0.2em",
|
||||
marginTop: "1.1em",
|
||||
marginBottom: "0.5em",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3
|
||||
style={{
|
||||
color: "var(--text-primary)",
|
||||
marginTop: "1em",
|
||||
marginBottom: "0.4em",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<h4
|
||||
style={{
|
||||
color: "var(--text-primary)",
|
||||
marginTop: "0.8em",
|
||||
marginBottom: "0.3em",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
h5: ({ children }) => (
|
||||
<h5
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
marginTop: "0.8em",
|
||||
marginBottom: "0.3em",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</h5>
|
||||
),
|
||||
h6: ({ children }) => (
|
||||
<h6
|
||||
style={{
|
||||
color: "var(--text-muted)",
|
||||
marginTop: "0.8em",
|
||||
marginBottom: "0.3em",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</h6>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p
|
||||
style={{
|
||||
color: "var(--text-primary)",
|
||||
lineHeight: 1.7,
|
||||
marginBottom: "0.75em",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: "var(--accent-interactive)",
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
strong: ({ children }) => (
|
||||
<strong style={{ color: "var(--text-primary)", fontWeight: 600 }}>
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
em: ({ children }) => (
|
||||
<em style={{ color: "var(--text-secondary)" }}>{children}</em>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote
|
||||
style={{
|
||||
borderLeft: "3px solid var(--border-strong)",
|
||||
margin: "0.75em 0",
|
||||
padding: "4px 16px",
|
||||
color: "var(--text-secondary)",
|
||||
background: "var(--bg-muted)",
|
||||
borderRadius: "0 4px 4px 0",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
hr: () => (
|
||||
<hr
|
||||
style={{
|
||||
border: "none",
|
||||
borderTop: "1px solid var(--border-default)",
|
||||
margin: "1.2em 0",
|
||||
}}
|
||||
/>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul
|
||||
style={{
|
||||
color: "var(--text-primary)",
|
||||
paddingLeft: "1.5em",
|
||||
marginBottom: "0.75em",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol
|
||||
style={{
|
||||
color: "var(--text-primary)",
|
||||
paddingLeft: "1.5em",
|
||||
marginBottom: "0.75em",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li
|
||||
style={{
|
||||
color: "var(--text-primary)",
|
||||
lineHeight: 1.6,
|
||||
marginBottom: "0.2em",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
code: ({ children, className }) => {
|
||||
const isBlock = className?.startsWith("language-");
|
||||
if (isBlock) return <code className={className}>{children}</code>;
|
||||
return (
|
||||
<code
|
||||
style={{
|
||||
background: "var(--bg-muted)",
|
||||
color: "var(--code-kw-color)",
|
||||
border: "1px solid var(--border-subtle)",
|
||||
borderRadius: 3,
|
||||
padding: "1px 5px",
|
||||
fontSize: "0.85em",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => {
|
||||
const codeText = React.isValidElement(children)
|
||||
? String((children.props as { children?: unknown }).children ?? "")
|
||||
@@ -44,12 +217,15 @@ const components: Components = {
|
||||
<div style={{ position: "relative", margin: "8px 0" }}>
|
||||
<pre
|
||||
style={{
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
background: "var(--bg-muted)",
|
||||
color: "var(--text-primary)",
|
||||
border: "1px solid var(--border-default)",
|
||||
padding: "10px 52px 10px 14px",
|
||||
borderRadius: 4,
|
||||
overflowX: "auto",
|
||||
fontSize: "0.85em",
|
||||
margin: 0,
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -65,18 +241,33 @@ const components: Components = {
|
||||
borderCollapse: "collapse",
|
||||
width: "100%",
|
||||
fontSize: "0.85em",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => (
|
||||
<thead style={{ background: "var(--bg-muted)" }}>{children}</thead>
|
||||
),
|
||||
tbody: ({ children }) => <tbody>{children}</tbody>,
|
||||
tr: ({ children }) => (
|
||||
<tr
|
||||
style={{
|
||||
borderBottom: "1px solid var(--border-default)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
),
|
||||
th: ({ children, style }) => (
|
||||
<th
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
border: "1px solid var(--border-default)",
|
||||
padding: "6px 10px",
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
background: "var(--bg-raised)",
|
||||
color: "var(--text-primary)",
|
||||
textAlign: "left",
|
||||
fontWeight: 600,
|
||||
whiteSpace: "nowrap",
|
||||
@@ -89,8 +280,9 @@ const components: Components = {
|
||||
td: ({ children, style }) => (
|
||||
<td
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
border: "1px solid var(--border-default)",
|
||||
padding: "5px 10px",
|
||||
color: "var(--text-primary)",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -92,8 +92,10 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) {
|
||||
maxWidth: 800,
|
||||
margin: "0 auto",
|
||||
padding: "20px 28px",
|
||||
background: "#ffffff",
|
||||
background: "var(--bg-surface)",
|
||||
color: "var(--text-primary)",
|
||||
borderRadius: 6,
|
||||
border: "1px solid var(--border-subtle)",
|
||||
}}
|
||||
>
|
||||
{renderedMarkdown}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Theme constants and utilities
|
||||
|
||||
export type ThemeMode = "light" | "dark" | "rainbow";
|
||||
export type ThemeMode = "light" | "dark" | "midnight" | "rainbow";
|
||||
|
||||
// Detect OS theme preference
|
||||
export function getSystemTheme(): "light" | "dark" {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { PageEditorFunctions } from "@app/types/pageEditor";
|
||||
import {
|
||||
type ToolPanelMode,
|
||||
DEFAULT_TOOL_PANEL_MODE,
|
||||
} from "@app/constants/toolPanel";
|
||||
import { type ToolPanelMode } from "@app/constants/toolPanel";
|
||||
import { preferencesService } from "@app/services/preferencesService";
|
||||
|
||||
export interface ToolWorkflowState {
|
||||
// UI State
|
||||
@@ -43,7 +41,7 @@ export const baseState: Omit<ToolWorkflowState, "toolPanelMode"> = {
|
||||
|
||||
export const createInitialState = (): ToolWorkflowState => ({
|
||||
...baseState,
|
||||
toolPanelMode: DEFAULT_TOOL_PANEL_MODE,
|
||||
toolPanelMode: preferencesService.getPreference("defaultToolPanelMode"),
|
||||
});
|
||||
|
||||
export function toolWorkflowReducer(
|
||||
|
||||
@@ -13,6 +13,31 @@ interface UseToolPanelGeometryOptions {
|
||||
quickAccessRef: RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
function computeGeometry(
|
||||
panelEl: HTMLDivElement,
|
||||
quickAccessRef: RefObject<HTMLDivElement | null>,
|
||||
): ToolPanelGeometry {
|
||||
const rect = panelEl.getBoundingClientRect();
|
||||
const isRTL =
|
||||
typeof document !== "undefined" && document.documentElement.dir === "rtl";
|
||||
let width: number;
|
||||
let left: number;
|
||||
|
||||
if (isRTL) {
|
||||
// RTL: panel is on the left, expands rightward
|
||||
width = Math.max(360, window.innerWidth - rect.right);
|
||||
left = rect.right;
|
||||
} else {
|
||||
// LTR: panel is on the right, expands leftward to the file sidebar
|
||||
const quickAccessRect = quickAccessRef.current?.getBoundingClientRect();
|
||||
const leftOffset = quickAccessRect ? quickAccessRect.right : 0;
|
||||
width = Math.max(360, rect.right - leftOffset);
|
||||
left = leftOffset;
|
||||
}
|
||||
const height = Math.max(rect.height, window.innerHeight - rect.top);
|
||||
return { left, top: rect.top, width, height };
|
||||
}
|
||||
|
||||
export function useToolPanelGeometry({
|
||||
enabled,
|
||||
toolPanelRef,
|
||||
@@ -36,31 +61,7 @@ export function useToolPanelGeometry({
|
||||
let rafId: number | null = null;
|
||||
|
||||
const computeAndSetGeometry = () => {
|
||||
const rect = panelEl.getBoundingClientRect();
|
||||
const isRTL =
|
||||
typeof document !== "undefined" &&
|
||||
document.documentElement.dir === "rtl";
|
||||
let width: number;
|
||||
let left: number;
|
||||
|
||||
if (isRTL) {
|
||||
// RTL: panel is on the left, expands rightward
|
||||
width = Math.max(360, window.innerWidth - rect.right);
|
||||
left = rect.right;
|
||||
} else {
|
||||
// LTR: panel is on the right, expands leftward to the file sidebar
|
||||
const quickAccessRect = quickAccessRef.current?.getBoundingClientRect();
|
||||
const leftOffset = quickAccessRect ? quickAccessRect.right : 0;
|
||||
width = Math.max(360, rect.right - leftOffset);
|
||||
left = leftOffset;
|
||||
}
|
||||
const height = Math.max(rect.height, window.innerHeight - rect.top);
|
||||
setGeometry({
|
||||
left,
|
||||
top: rect.top,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
setGeometry(computeGeometry(panelEl, quickAccessRef));
|
||||
};
|
||||
|
||||
const scheduleUpdate = () => {
|
||||
|
||||
@@ -175,9 +175,13 @@ export function useRainbowTheme(): RainbowThemeHook {
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal theme switching
|
||||
const nextTheme = themeMode === "light" ? "dark" : "light";
|
||||
updatePreference("theme", nextTheme);
|
||||
// Normal theme switching: light → dark → midnight → light
|
||||
const cycle: Record<string, ThemeMode> = {
|
||||
light: "dark",
|
||||
dark: "midnight",
|
||||
midnight: "light",
|
||||
};
|
||||
updatePreference("theme", cycle[themeMode] ?? "light");
|
||||
}, [themeMode, updatePreference]);
|
||||
|
||||
const activateRainbow = useCallback(() => {
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import AppsIcon from "@mui/icons-material/AppsRounded";
|
||||
|
||||
import ToolPanel from "@app/components/tools/ToolPanel";
|
||||
import RightSidebar from "@app/components/tools/RightSidebar";
|
||||
import Workbench from "@app/components/layout/Workbench";
|
||||
import FileSidebar from "@app/components/shared/FileSidebar";
|
||||
import FileManager from "@app/components/FileManager";
|
||||
@@ -290,7 +290,7 @@ export default function HomePage() {
|
||||
aria-label={t("home.mobile.toolsSlide", "Tool selection panel")}
|
||||
>
|
||||
<div className="mobile-slide-content">
|
||||
<ToolPanel />
|
||||
<RightSidebar />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -381,7 +381,7 @@ export default function HomePage() {
|
||||
onOpenSettings={() => setConfigModalOpen(true)}
|
||||
/>
|
||||
<Workbench />
|
||||
{!hideToolPanel && <ToolPanel />}
|
||||
{!hideToolPanel && <RightSidebar />}
|
||||
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
|
||||
<AppConfigModal
|
||||
opened={configModalOpen}
|
||||
|
||||
@@ -14,12 +14,6 @@
|
||||
--spdf-compare-inline-added-bg: rgba(52, 199, 89, 0.25);
|
||||
|
||||
/* File Manager active file colors */
|
||||
--file-active-bg: rgba(
|
||||
59,
|
||||
130,
|
||||
246,
|
||||
0.1
|
||||
); /* Transparent blue for active file background */
|
||||
--file-active-badge-bg: rgba(
|
||||
34,
|
||||
197,
|
||||
@@ -43,9 +37,7 @@
|
||||
--fullscreen-anim-duration-out: 0.22s;
|
||||
|
||||
/* Z-index constants (added in this PR) */
|
||||
--z-over-fullscreen-surface: 1400;
|
||||
--z-fullscreen-surface: 1200;
|
||||
--z-fullscreen-favorite-star: 2;
|
||||
--z-fullscreen-icon-svg: 1;
|
||||
--z-toolpicker-star: 1;
|
||||
|
||||
@@ -162,10 +154,8 @@
|
||||
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* Font weights */
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
/* Light theme semantic colors */
|
||||
--bg-surface: #ffffff;
|
||||
@@ -192,22 +182,9 @@
|
||||
/* Icon colors for light mode */
|
||||
--icon-user-bg: #9ca3af;
|
||||
--icon-user-color: #ffffff;
|
||||
--icon-notifications-bg: #9ca3af;
|
||||
--icon-notifications-color: #ffffff;
|
||||
--icon-tools-bg: #1e88e5;
|
||||
--icon-tools-color: #ffffff;
|
||||
--icon-read-bg: #4caf50;
|
||||
--icon-read-color: #ffffff;
|
||||
--icon-sign-bg: #3ba99c;
|
||||
--icon-sign-color: #ffffff;
|
||||
--icon-automate-bg: #a576e3;
|
||||
--icon-automate-color: #ffffff;
|
||||
--icon-files-bg: #d3e7f7;
|
||||
--icon-files-color: #0a8bff;
|
||||
--icon-activity-bg: #d3e7f7;
|
||||
--icon-activity-color: #0a8bff;
|
||||
--icon-config-bg: #9ca3af;
|
||||
--icon-config-color: #ffffff;
|
||||
|
||||
/* Colors for tooltips */
|
||||
--tooltip-title-bg: #dbefff;
|
||||
@@ -224,7 +201,6 @@
|
||||
--tools-text-and-icon-color: var(--text-primary);
|
||||
|
||||
/* Tool picker sticky header variables (light mode) */
|
||||
--tool-header-bg: #dbefff;
|
||||
--tool-header-border: #bee2ff;
|
||||
--tool-header-text: #1e88e5;
|
||||
--tool-header-badge-bg: #c0ddff;
|
||||
@@ -235,7 +211,6 @@
|
||||
--tool-subcategory-rule-color: #e5e7eb; /* doubly lighter rule line */
|
||||
--accent-interactive: #4a90e2;
|
||||
--text-instruction: #4a90e2;
|
||||
--text-brand: var(--color-gray-700);
|
||||
--text-brand-accent: #dc2626;
|
||||
|
||||
/* PDF text selection colors */
|
||||
@@ -251,16 +226,13 @@
|
||||
--tool-panel-search-border-bottom: #eff1f4;
|
||||
|
||||
/* container */
|
||||
--landing-paper-bg: var(--bg-surface);
|
||||
--landing-inner-paper-bg: #eef8ff;
|
||||
--landing-inner-paper-border: #cdeaff;
|
||||
--landing-button-bg: var(--bg-surface);
|
||||
--landing-button-color: var(--icon-tools-bg);
|
||||
--landing-button-border: #e0f2f7;
|
||||
--landing-button-hover-bg: rgb(251, 251, 251);
|
||||
|
||||
/* drop state */
|
||||
--landing-drop-paper-bg: #e3f2fd;
|
||||
--landing-drop-inner-paper-bg: #bbdefb;
|
||||
--landing-drop-inner-paper-border: #90caf9;
|
||||
|
||||
@@ -268,36 +240,15 @@
|
||||
--landing-hero-gradient: linear-gradient(135deg, #4c8bf5 0%, #3a7be8 100%);
|
||||
--landing-stack-w: 224px;
|
||||
--landing-stack-h: 176px;
|
||||
--landing-stack-glow-bg: radial-gradient(
|
||||
circle,
|
||||
rgba(74, 144, 226, 0.18) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
|
||||
/* landing doc stack shadows */
|
||||
--landing-doc-shadow-back-idle:
|
||||
0 4px 20px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
--landing-doc-shadow-back-hover:
|
||||
0 12px 40px rgba(0, 0, 0, 0.15), 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
--landing-doc-shadow-front-idle:
|
||||
0 8px 30px rgba(0, 0, 0, 0.12), 0 4px 12px rgba(0, 0, 0, 0.06),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.02);
|
||||
--landing-doc-shadow-front-hover:
|
||||
0 18px 48px rgba(0, 0, 0, 0.18), 0 8px 20px rgba(0, 0, 0, 0.1),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
|
||||
/* landing action button shadows */
|
||||
--landing-action-transition:
|
||||
transform 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.32s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--landing-action-shadow-idle:
|
||||
0 6px 18px rgba(0, 0, 0, 0), 0 2px 6px rgba(0, 0, 0, 0);
|
||||
--landing-action-shadow-hover:
|
||||
0 6px 18px rgba(0, 0, 0, 0.14), 0 2px 6px rgba(0, 0, 0, 0.08);
|
||||
--landing-action-primary-shadow-idle:
|
||||
0 10px 26px rgba(58, 123, 232, 0), 0 4px 12px rgba(0, 0, 0, 0);
|
||||
--landing-action-primary-shadow-hover:
|
||||
0 10px 26px rgba(58, 123, 232, 0.42), 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* selected file header colors */
|
||||
--header-selected-bg: #1e88e5; /* light mode selected header matches dark */
|
||||
@@ -306,18 +257,10 @@
|
||||
--accordion-item-bg: #e8eaed; /* accordion item background - more distinguishable */
|
||||
|
||||
/* shadows */
|
||||
--drop-shadow-color: rgba(0, 0, 0, 0.08);
|
||||
--drop-shadow-color-strong: rgba(0, 0, 0, 0.04);
|
||||
--drop-shadow-filter: drop-shadow(0 0.2rem 0.4rem rgba(0, 0, 0, 0.08))
|
||||
drop-shadow(0 0.6rem 0.6rem rgba(0, 0, 0, 0.06))
|
||||
drop-shadow(0 1.2rem 1rem rgba(0, 0, 0, 0.04));
|
||||
|
||||
/* Light mode card hover and selection */
|
||||
--header-hover-bg: #3b4b6e; /* same family as selected, a touch muted for hover */
|
||||
--card-selected-border: #3fafff; /* slightly more blue than dark mode header */
|
||||
--checkbox-border: #2f83bf;
|
||||
--checkbox-checked-bg: #3fafff;
|
||||
--checkbox-tick: #ffffff;
|
||||
|
||||
--information-text-bg: #eaeaea;
|
||||
--information-text-color: #5e5e5e;
|
||||
@@ -348,15 +291,9 @@
|
||||
--api-keys-button-color: #333333;
|
||||
|
||||
/* PDF Report Colors (always light) */
|
||||
--pdf-light-header-bg: 239 246 255;
|
||||
--pdf-light-accent: 59 130 246;
|
||||
--pdf-light-text-primary: 30 41 59;
|
||||
--pdf-light-text-muted: 100 116 139;
|
||||
--pdf-light-box-bg: 248 250 252;
|
||||
--pdf-light-box-border: 226 232 240;
|
||||
--pdf-light-warning: 234 179 8;
|
||||
--pdf-light-danger: 248 113 113;
|
||||
--pdf-light-success: 34 197 94;
|
||||
--pdf-light-neutral: 148 163 184;
|
||||
--pdf-light-status-valid-bg: 209 250 229;
|
||||
--pdf-light-status-valid-text: 6 95 70;
|
||||
@@ -366,8 +303,6 @@
|
||||
--pdf-light-status-invalid-text: 153 27 27;
|
||||
--pdf-light-status-neutral-bg: 229 231 235;
|
||||
--pdf-light-status-neutral-text: 55 65 81;
|
||||
--pdf-light-report-container-bg: 249 250 251;
|
||||
--pdf-light-simulated-page-bg: 255 255 255;
|
||||
--pdf-light-simulated-page-text: 15 23 42;
|
||||
|
||||
/* Code token colors (light mode) */
|
||||
@@ -376,11 +311,6 @@
|
||||
--code-num-color: #4338ca; /* indigo-700 */
|
||||
--code-com-color: #6b7280; /* gray-500 */
|
||||
/* Compare tool specific colors - only for colors that don't have existing theme pairs */
|
||||
--compare-upload-dropzone-bg: rgba(241, 245, 249, 0.45);
|
||||
--compare-upload-dropzone-border: rgba(148, 163, 184, 0.6);
|
||||
--compare-upload-icon-bg: rgba(148, 163, 184, 0.2);
|
||||
--compare-upload-icon-color: rgba(17, 24, 39, 0.75);
|
||||
--compare-upload-divider: rgba(148, 163, 184, 0.5);
|
||||
|
||||
/* Compare page label chip (light mode): slightly lighter than surrounding rows */
|
||||
--compare-page-label-bg: var(--bg-muted);
|
||||
@@ -420,12 +350,12 @@
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] {
|
||||
/* Dark theme gray scale (inverted) */
|
||||
--gray-50: 17 24 39;
|
||||
--gray-100: 31 35 41;
|
||||
--gray-200: 42 47 54;
|
||||
--gray-300: 55 65 81;
|
||||
--gray-400: 75 85 99;
|
||||
/* Dark theme gray scale — very dark, near-black */
|
||||
--gray-50: 7 10 14;
|
||||
--gray-100: 10 13 18;
|
||||
--gray-200: 15 19 24;
|
||||
--gray-300: 22 28 36;
|
||||
--gray-400: 36 44 58;
|
||||
--gray-500: 107 114 128;
|
||||
--gray-600: 156 163 175;
|
||||
--gray-700: 209 213 219;
|
||||
@@ -433,9 +363,9 @@
|
||||
--gray-900: 243 244 246;
|
||||
|
||||
/* Dark semantic colors for Tailwind */
|
||||
--surface: 31 35 41;
|
||||
--background: 42 47 54;
|
||||
--border: 55 65 81;
|
||||
--surface: 15 19 24;
|
||||
--background: 10 13 18;
|
||||
--border: 22 28 36;
|
||||
|
||||
/* Dark theme Mantine colors */
|
||||
--color-red-50: #2d1b1b;
|
||||
@@ -449,11 +379,11 @@
|
||||
--color-red-800: #991b1b;
|
||||
--color-red-900: #7f1d1d;
|
||||
|
||||
--color-gray-50: #111827;
|
||||
--color-gray-100: #1f2329;
|
||||
--color-gray-200: #2a2f36;
|
||||
--color-gray-300: #374151;
|
||||
--color-gray-400: #4b5563;
|
||||
--color-gray-50: #070a0e;
|
||||
--color-gray-100: #0a0d12;
|
||||
--color-gray-200: #0f1318;
|
||||
--color-gray-300: #161c24;
|
||||
--color-gray-400: #242c3a;
|
||||
--color-gray-500: #6b7280;
|
||||
--color-gray-600: #9ca3af;
|
||||
--color-gray-700: #d1d5db;
|
||||
@@ -461,20 +391,20 @@
|
||||
--color-gray-900: #f3f4f6;
|
||||
|
||||
/* Category colors - same as light mode for consistency */
|
||||
--category-color-removal: #ef4444; /* Red for removal tools */
|
||||
--category-color-security: #f59e0b; /* Orange for security tools */
|
||||
--category-color-formatting: #8b5cf6; /* Purple for formatting tools */
|
||||
--category-color-extraction: #06b6d4; /* Cyan for extraction tools */
|
||||
--category-color-signing: #10b981; /* Green for signing tools */
|
||||
--category-color-general: #3b82f6; /* Blue for general tools */
|
||||
--category-color-verification: #f97316; /* Orange for verification tools */
|
||||
--category-color-automation: #ec4899; /* Pink for automation tools */
|
||||
--category-color-developer: #6b7280; /* Gray for developer tools */
|
||||
--category-color-default: #6b7280; /* Default gray */
|
||||
--category-color-removal: #ef4444;
|
||||
--category-color-security: #f59e0b;
|
||||
--category-color-formatting: #8b5cf6;
|
||||
--category-color-extraction: #06b6d4;
|
||||
--category-color-signing: #10b981;
|
||||
--category-color-general: #3b82f6;
|
||||
--category-color-verification: #f97316;
|
||||
--category-color-automation: #ec4899;
|
||||
--category-color-developer: #6b7280;
|
||||
--category-color-default: #6b7280;
|
||||
|
||||
/* Special section colors - same as light mode for consistency */
|
||||
--special-color-favorites: #ffc107; /* Yellow/gold for favorites */
|
||||
--special-color-recommended: #1bb1d4; /* Cyan for recommended */
|
||||
--special-color-favorites: #ffc107;
|
||||
--special-color-recommended: #1bb1d4;
|
||||
|
||||
/* Success (green) - dark */
|
||||
--color-green-50: #052e16;
|
||||
@@ -501,53 +431,39 @@
|
||||
--color-yellow-900: #fef9c3;
|
||||
|
||||
/* Dark theme semantic colors */
|
||||
--bg-surface: #2a2f36;
|
||||
--bg-raised: #1f2329;
|
||||
--bg-muted: #1f2329;
|
||||
--bg-background: #2a2f36;
|
||||
--bg-toolbar: #1f2329;
|
||||
--bg-file-manager: #1f2329;
|
||||
--bg-file-list: #2a2f36;
|
||||
--bg-surface: #0f1318;
|
||||
--bg-raised: #0a0d12;
|
||||
--bg-muted: #0a0d12;
|
||||
--bg-background: #0a0d12;
|
||||
--bg-toolbar: #0a0d12;
|
||||
--bg-file-manager: #0a0d12;
|
||||
--bg-file-list: #0f1318;
|
||||
--btn-open-file: #0a8bff;
|
||||
--text-primary: #f9fafb;
|
||||
--text-secondary: #d1d5db;
|
||||
--text-muted: #9ca3af;
|
||||
/* Always-dark text (for use on light backgrounds like alerts) - does not change in dark mode */
|
||||
--text-primary: #f0f2f5;
|
||||
--text-secondary: #c5ccd6;
|
||||
--text-muted: #8b96a3;
|
||||
--text-always-dark: #1f2937;
|
||||
--text-always-dark-muted: #6b7280;
|
||||
--border-subtle: #2a2f36;
|
||||
--border-default: #3a4047;
|
||||
--border-strong: #4b5563;
|
||||
--hover-bg: #374151;
|
||||
--active-bg: #4b5563;
|
||||
--border-subtle: #0f1318;
|
||||
--border-default: #161c24;
|
||||
--border-strong: #242c3a;
|
||||
--hover-bg: #13181f;
|
||||
--active-bg: #1a2030;
|
||||
--automation-entry-hover-bg: var(--color-gray-200);
|
||||
|
||||
/* Icon colors for dark mode */
|
||||
--icon-user-bg: #2a2f36;
|
||||
--icon-user-color: #6e7581;
|
||||
--icon-notifications-bg: #2a2f36;
|
||||
--icon-notifications-color: #6e7581;
|
||||
--icon-tools-bg: #4b525a;
|
||||
--icon-tools-color: #eaeaea;
|
||||
--icon-read-bg: #4b525a;
|
||||
--icon-read-color: #eaeaea;
|
||||
--icon-sign-bg: #4b525a;
|
||||
--icon-sign-color: #eaeaea;
|
||||
--icon-automate-bg: #4b525a;
|
||||
--icon-automate-color: #eaeaea;
|
||||
--icon-files-bg: #4b525a;
|
||||
--icon-files-color: #eaeaea;
|
||||
--icon-activity-bg: #4b525a;
|
||||
--icon-activity-color: #eaeaea;
|
||||
--icon-config-bg: #4b525a;
|
||||
--icon-config-color: #eaeaea;
|
||||
--icon-user-bg: #0f1318;
|
||||
--icon-user-color: #5a6475;
|
||||
--icon-tools-bg: #161c24;
|
||||
--icon-tools-color: #d8dce4;
|
||||
--icon-files-color: #d8dce4;
|
||||
|
||||
/* Inactive icon colors for dark mode */
|
||||
--icon-inactive-bg: #2a2f36;
|
||||
--icon-inactive-color: #6e7581;
|
||||
--icon-inactive-bg: #0f1318;
|
||||
--icon-inactive-color: #5a6475;
|
||||
|
||||
/* Dark mode tooltip colors */
|
||||
--tooltip-title-bg: #4b525a;
|
||||
--tooltip-title-bg: #161c24;
|
||||
--tooltip-title-color: #fff;
|
||||
--tooltip-header-bg: var(--bg-raised);
|
||||
--tooltip-header-color: var(--text-primary);
|
||||
@@ -555,7 +471,6 @@
|
||||
|
||||
--accent-interactive: #ffffff;
|
||||
--text-instruction: #ffffff;
|
||||
--text-brand: var(--color-gray-800);
|
||||
--text-brand-accent: #ef4444;
|
||||
|
||||
/* Compare badge text colors (dark mode): lighter for readability */
|
||||
@@ -563,62 +478,41 @@
|
||||
--spdf-compare-added-badge-fg: var(--color-green-500);
|
||||
|
||||
/* container */
|
||||
--landing-paper-bg: #171a1f;
|
||||
--landing-inner-paper-bg: var(--bg-raised);
|
||||
--landing-inner-paper-border: #2d3237;
|
||||
--landing-inner-paper-border: #161c24;
|
||||
|
||||
/* landing dark overrides */
|
||||
--landing-stack-glow-bg: radial-gradient(
|
||||
circle,
|
||||
rgba(30, 136, 229, 0.22) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
--landing-doc-shadow-back-idle:
|
||||
0 4px 20px rgba(0, 0, 0, 0.35), 0 1px 3px rgba(0, 0, 0, 0.25);
|
||||
--landing-doc-shadow-back-hover:
|
||||
0 14px 44px rgba(0, 0, 0, 0.55), 0 6px 16px rgba(0, 0, 0, 0.35);
|
||||
0 4px 20px rgba(0, 0, 0, 0.6), 0 1px 3px rgba(0, 0, 0, 0.4);
|
||||
--landing-doc-shadow-front-idle:
|
||||
0 8px 30px rgba(0, 0, 0, 0.45), 0 4px 12px rgba(0, 0, 0, 0.3),
|
||||
0 8px 30px rgba(0, 0, 0, 0.7), 0 4px 12px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||
--landing-doc-shadow-front-hover:
|
||||
0 20px 52px rgba(0, 0, 0, 0.6), 0 10px 24px rgba(0, 0, 0, 0.4),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
--landing-button-color: #ffffff;
|
||||
--landing-button-hover-bg: var(--bg-raised);
|
||||
|
||||
/* selected file header colors for dark */
|
||||
--header-selected-bg: #1e88e5;
|
||||
--header-selected-fg: #ffffff;
|
||||
/* file card background (dark) */
|
||||
--file-card-bg: #1f2329;
|
||||
--accordion-item-bg: #373d45; /* accordion item background - more distinguishable */
|
||||
|
||||
/* shadows */
|
||||
--drop-shadow-color: rgba(255, 255, 255, 0.08);
|
||||
--drop-shadow-color-strong: rgba(255, 255, 255, 0.04);
|
||||
--drop-shadow-filter: drop-shadow(0 0.2rem 0.4rem rgba(200, 200, 200, 0.08))
|
||||
drop-shadow(0 0.6rem 0.6rem rgba(200, 200, 200, 0.06))
|
||||
drop-shadow(0 1.2rem 1rem rgba(200, 200, 200, 0.04));
|
||||
--file-card-bg: #0a0d12;
|
||||
--accordion-item-bg: #13181f;
|
||||
|
||||
/* Adjust shadows for dark mode */
|
||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.4);
|
||||
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.4);
|
||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.6);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.6);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.6);
|
||||
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.6);
|
||||
|
||||
--tools-text-and-icon-color: var(--text-primary);
|
||||
|
||||
/* Tool picker sticky header variables (dark mode) */
|
||||
--tool-header-bg: #2a2f36;
|
||||
--tool-header-border: #3a4047;
|
||||
--tool-header-text: #d0d6dc;
|
||||
--tool-header-badge-bg: #4b525a;
|
||||
--tool-header-badge-text: #ffffff;
|
||||
--tool-header-border: #161c24;
|
||||
--tool-header-text: #c5ccd6;
|
||||
--tool-header-badge-bg: #161c24;
|
||||
--tool-header-badge-text: #f0f2f5;
|
||||
|
||||
/* Subcategory title styling (dark mode) */
|
||||
--tool-subcategory-text-color: #9ca3af; /* lighter text in dark mode as well */
|
||||
--tool-subcategory-rule-color: #3a4047; /* doubly lighter (relative) line in dark */
|
||||
--tool-subcategory-text-color: #8b96a3;
|
||||
--tool-subcategory-rule-color: #161c24;
|
||||
|
||||
/* PDF text selection colors (dark mode) */
|
||||
--pdf-selection-bg: rgba(96, 165, 250, 0.25);
|
||||
@@ -626,74 +520,67 @@
|
||||
|
||||
/* Placeholder text colors (dark mode) */
|
||||
--search-text-and-icon-color: #ffffff !important;
|
||||
--input-bg: #4b525a;
|
||||
--input-bg: #161c24;
|
||||
|
||||
/* Tool panel search bar background colors (dark mode) */
|
||||
--tool-panel-search-bg: #1f2329;
|
||||
--tool-panel-search-border-bottom: #4b525a;
|
||||
--tool-panel-search-bg: #0a0d12;
|
||||
--tool-panel-search-border-bottom: #242c3a;
|
||||
|
||||
--information-text-bg: #292e34;
|
||||
--information-text-color: #ececec;
|
||||
--information-text-bg: #0a0d12;
|
||||
--information-text-color: #d8dce4;
|
||||
|
||||
/* Bulk selection panel specific colors (dark mode) */
|
||||
--bulk-panel-bg: var(--bg-raised); /* dark background for parent container */
|
||||
--bulk-card-bg: var(--bg-raised); /* dark background for cards */
|
||||
--bulk-card-border: var(
|
||||
--border-default
|
||||
); /* default border for cards and buttons */
|
||||
--bulk-card-hover-border: var(--border-strong); /* stronger border on hover */
|
||||
--unsupported-bar-bg: #1f2329;
|
||||
--unsupported-bar-border: #4b525a;
|
||||
--bulk-panel-bg: var(--bg-raised);
|
||||
--bulk-card-bg: var(--bg-raised);
|
||||
--bulk-card-border: var(--border-default);
|
||||
--bulk-card-hover-border: var(--border-strong);
|
||||
--unsupported-bar-bg: #0a0d12;
|
||||
--unsupported-bar-border: #242c3a;
|
||||
|
||||
/* Config Modal colors (dark mode) */
|
||||
--modal-nav-bg: #1f2329;
|
||||
--modal-nav-section-title: #9ca3af;
|
||||
--modal-nav-item: #d0d6dc;
|
||||
--modal-nav-bg: #0a0d12;
|
||||
--modal-nav-section-title: #8b96a3;
|
||||
--modal-nav-item: #c5ccd6;
|
||||
--modal-nav-item-active: #0a8bff;
|
||||
--modal-nav-item-active-bg: rgba(10, 139, 255, 0.15);
|
||||
--modal-content-bg: #2a2f36;
|
||||
--modal-content-bg: #0f1318;
|
||||
--modal-header-border: rgba(255, 255, 255, 0.08);
|
||||
|
||||
/* Onboarding (dark mode) */
|
||||
--onboarding-title: #f9fafb;
|
||||
--onboarding-body: #d1d5db;
|
||||
--onboarding-title: #f0f2f5;
|
||||
--onboarding-body: #c5ccd6;
|
||||
--onboarding-primary-button-bg: #ffffff;
|
||||
--onboarding-primary-button-text: #0b1220;
|
||||
--onboarding-primary-button-text: #0a0d12;
|
||||
--onboarding-secondary-button-bg: transparent;
|
||||
--onboarding-secondary-button-text: #e5e7eb;
|
||||
--onboarding-secondary-button-border: #3a4047;
|
||||
--onboarding-step-active: #e5e7eb;
|
||||
--onboarding-step-inactive: #4b5563;
|
||||
--onboarding-secondary-button-text: #d8dce4;
|
||||
--onboarding-secondary-button-border: #161c24;
|
||||
--onboarding-step-active: #d8dce4;
|
||||
--onboarding-step-inactive: #242c3a;
|
||||
|
||||
/* API Keys section colors (dark mode) */
|
||||
--api-keys-card-bg: #2a2f36;
|
||||
--api-keys-card-border: #3a4047;
|
||||
--api-keys-card-bg: #0f1318;
|
||||
--api-keys-card-border: #161c24;
|
||||
--api-keys-card-shadow: none;
|
||||
--api-keys-input-bg: #1f2329;
|
||||
--api-keys-input-border: #3a4047;
|
||||
--api-keys-button-bg: #3a4047;
|
||||
--api-keys-button-color: #d0d6dc;
|
||||
--api-keys-input-bg: #0a0d12;
|
||||
--api-keys-input-border: #161c24;
|
||||
--api-keys-button-bg: #161c24;
|
||||
--api-keys-button-color: #c5ccd6;
|
||||
|
||||
/* Code token colors (dark mode - Cursor-like) */
|
||||
--code-kw-color: #c792ea; /* purple */
|
||||
--code-str-color: #c3e88d; /* green */
|
||||
--code-num-color: #f78c6c; /* orange */
|
||||
--code-com-color: #697098; /* muted gray-blue */
|
||||
/* Compare tool specific colors (dark mode) - only for colors that don't have existing theme pairs */
|
||||
--compare-upload-dropzone-bg: rgba(31, 35, 41, 0.45);
|
||||
--compare-upload-dropzone-border: rgba(75, 85, 99, 0.6);
|
||||
--compare-upload-icon-bg: rgba(75, 85, 99, 0.2);
|
||||
--compare-upload-icon-color: rgba(243, 244, 246, 0.75);
|
||||
--compare-upload-divider: rgba(75, 85, 99, 0.5);
|
||||
--code-kw-color: #c792ea;
|
||||
--code-str-color: #c3e88d;
|
||||
--code-num-color: #f78c6c;
|
||||
--code-com-color: #697098;
|
||||
|
||||
/* Compare page label chip (dark mode): slightly darker than surrounding rows */
|
||||
--compare-page-label-bg: #1f2329;
|
||||
/* Compare page label chip (dark mode) */
|
||||
--compare-page-label-bg: #0a0d12;
|
||||
--compare-page-label-fg: var(--text-secondary);
|
||||
|
||||
/* Status indicator dot ring shadow (dark) */
|
||||
--status-dot-ring: 0 0 0 2px rgba(255, 255, 255, 0.18);
|
||||
|
||||
/* Input element styling (dark) */
|
||||
--input-bg: #4b525a;
|
||||
--input-bg: #161c24;
|
||||
|
||||
/* Select/dropdown placeholder state (dark) */
|
||||
--select-placeholder-bg: var(--mantine-color-dark-5);
|
||||
@@ -709,6 +596,195 @@
|
||||
--dropdown-group-label: var(--mantine-color-dark-2);
|
||||
}
|
||||
|
||||
/* ── Midnight theme — blue-tinted dark workbench ── */
|
||||
/* Applied on top of dark mode: html has both data-mantine-color-scheme="dark"
|
||||
and data-color-scheme="midnight" */
|
||||
[data-color-scheme="midnight"] {
|
||||
--gray-50: 21 26 38;
|
||||
--gray-100: 30 36 52;
|
||||
--gray-200: 45 50 72;
|
||||
--gray-300: 58 65 90;
|
||||
--gray-400: 75 85 99;
|
||||
--gray-500: 107 114 128;
|
||||
--gray-600: 156 163 175;
|
||||
--gray-700: 209 213 219;
|
||||
--gray-800: 229 231 235;
|
||||
--gray-900: 243 244 246;
|
||||
|
||||
--surface: 30 36 52;
|
||||
--background: 21 26 38;
|
||||
--border: 58 65 90;
|
||||
|
||||
--color-gray-50: #151a24;
|
||||
--color-gray-100: #1a2030;
|
||||
--color-gray-200: #1e2435;
|
||||
--color-gray-300: #252836;
|
||||
--color-gray-400: #2d3340;
|
||||
--color-gray-500: #6b7280;
|
||||
--color-gray-600: #9ca3af;
|
||||
--color-gray-700: #d1d5db;
|
||||
--color-gray-800: #e5e7eb;
|
||||
--color-gray-900: #f3f4f6;
|
||||
|
||||
--category-color-general: #4b77e6;
|
||||
|
||||
--bg-surface: #1e2435;
|
||||
--bg-raised: #1a2030;
|
||||
--bg-muted: #1a2030;
|
||||
--bg-background: #151a24;
|
||||
--bg-toolbar: #1e2435;
|
||||
--bg-file-manager: #1a2030;
|
||||
--bg-file-list: #1e2435;
|
||||
--btn-open-file: #4b77e6;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-muted: #94a3b8;
|
||||
--text-always-dark: #1f2937;
|
||||
--text-always-dark-muted: #6b7280;
|
||||
--border-subtle: #2d3340;
|
||||
--border-default: #3a4152;
|
||||
--border-strong: #4a5568;
|
||||
--hover-bg: #252836;
|
||||
--active-bg: #2d3340;
|
||||
--automation-entry-hover-bg: var(--color-gray-200);
|
||||
|
||||
--icon-user-bg: #1e2435;
|
||||
--icon-user-color: #6b7280;
|
||||
--icon-tools-bg: #2d3340;
|
||||
--icon-tools-color: #cbd5e1;
|
||||
--icon-files-color: #4b77e6;
|
||||
--icon-inactive-bg: #1e2435;
|
||||
--icon-inactive-color: #6b7280;
|
||||
|
||||
--tooltip-title-bg: #252836;
|
||||
--tooltip-title-color: #f1f5f9;
|
||||
--tooltip-header-bg: #1a2030;
|
||||
--tooltip-header-color: #f1f5f9;
|
||||
--tooltip-border: var(--border-default);
|
||||
|
||||
--accent-interactive: #4b77e6;
|
||||
--text-instruction: #4b77e6;
|
||||
--text-brand-accent: #60a5fa;
|
||||
|
||||
--spdf-compare-removed-badge-fg: var(--color-red-500);
|
||||
--spdf-compare-added-badge-fg: var(--color-green-500);
|
||||
|
||||
--landing-inner-paper-bg: #1a2030;
|
||||
--landing-inner-paper-border: #3a4152;
|
||||
--landing-button-bg: #252836;
|
||||
--landing-button-color: #f1f5f9;
|
||||
--landing-button-border: #3a4152;
|
||||
--landing-drop-inner-paper-bg: #1e2435;
|
||||
--landing-drop-inner-paper-border: #3a4152;
|
||||
|
||||
--landing-doc-shadow-back-idle:
|
||||
0 4px 20px rgba(0, 0, 0, 0.45), 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
--landing-doc-shadow-front-idle:
|
||||
0 8px 30px rgba(0, 0, 0, 0.55), 0 4px 12px rgba(0, 0, 0, 0.4),
|
||||
0 0 0 1px rgba(75, 119, 230, 0.08);
|
||||
|
||||
--header-selected-bg: #4b77e6;
|
||||
--header-selected-fg: #ffffff;
|
||||
--file-card-bg: #1a2030;
|
||||
--accordion-item-bg: #252836;
|
||||
|
||||
--card-selected-border: #4b77e6;
|
||||
--checkbox-checked-bg: #4b77e6;
|
||||
|
||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.5);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
|
||||
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.5);
|
||||
|
||||
--tools-text-and-icon-color: #cbd5e1;
|
||||
|
||||
--tool-header-border: #3a4152;
|
||||
--tool-header-text: #cbd5e1;
|
||||
--tool-header-badge-bg: #252836;
|
||||
--tool-header-badge-text: #f1f5f9;
|
||||
|
||||
--tool-subcategory-text-color: #94a3b8;
|
||||
--tool-subcategory-rule-color: #3a4152;
|
||||
|
||||
--pdf-selection-bg: rgba(75, 119, 230, 0.25);
|
||||
--pdf-selection-ring: rgba(75, 119, 230, 0.28);
|
||||
|
||||
--search-text-and-icon-color: #f1f5f9 !important;
|
||||
--input-bg: #252836;
|
||||
|
||||
--tool-panel-search-bg: #1a2030;
|
||||
--tool-panel-search-border-bottom: #3a4152;
|
||||
|
||||
--information-text-bg: #1e2435;
|
||||
--information-text-color: #cbd5e1;
|
||||
|
||||
--bulk-panel-bg: var(--bg-raised);
|
||||
--bulk-card-bg: var(--bg-raised);
|
||||
--bulk-card-border: var(--border-default);
|
||||
--bulk-card-hover-border: var(--border-strong);
|
||||
--unsupported-bar-bg: #1a2030;
|
||||
--unsupported-bar-border: #3a4152;
|
||||
|
||||
--modal-nav-bg: #1a2030;
|
||||
--modal-nav-section-title: #94a3b8;
|
||||
--modal-nav-item: #cbd5e1;
|
||||
--modal-nav-item-active: #4b77e6;
|
||||
--modal-nav-item-active-bg: rgba(75, 119, 230, 0.18);
|
||||
--modal-content-bg: #1e2435;
|
||||
--modal-header-border: rgba(255, 255, 255, 0.08);
|
||||
|
||||
--onboarding-title: #f1f5f9;
|
||||
--onboarding-body: #cbd5e1;
|
||||
--onboarding-primary-button-bg: #4b77e6;
|
||||
--onboarding-primary-button-text: #ffffff;
|
||||
--onboarding-secondary-button-bg: transparent;
|
||||
--onboarding-secondary-button-text: #cbd5e1;
|
||||
--onboarding-secondary-button-border: #3a4152;
|
||||
--onboarding-step-active: #cbd5e1;
|
||||
--onboarding-step-inactive: #3a4152;
|
||||
|
||||
--api-keys-card-bg: #1e2435;
|
||||
--api-keys-card-border: #3a4152;
|
||||
--api-keys-card-shadow: none;
|
||||
--api-keys-input-bg: #1a2030;
|
||||
--api-keys-input-border: #3a4152;
|
||||
--api-keys-button-bg: #252836;
|
||||
--api-keys-button-color: #cbd5e1;
|
||||
|
||||
--code-kw-color: #7aa2f7;
|
||||
--code-str-color: #9ece6a;
|
||||
--code-num-color: #ff9e64;
|
||||
--code-com-color: #565f89;
|
||||
|
||||
--compare-page-label-bg: #1a2030;
|
||||
--compare-page-label-fg: var(--text-secondary);
|
||||
|
||||
--status-dot-ring: 0 0 0 2px rgba(75, 119, 230, 0.25);
|
||||
|
||||
--select-placeholder-bg: var(--mantine-color-dark-5);
|
||||
--select-placeholder-text: var(--mantine-color-dark-2);
|
||||
|
||||
--dropdown-trigger-bg: var(--mantine-color-dark-6);
|
||||
--dropdown-trigger-text: var(--mantine-color-dark-0);
|
||||
--dropdown-trigger-text-disabled: var(--mantine-color-dark-1);
|
||||
--dropdown-trigger-icon: var(--mantine-color-dark-2);
|
||||
--dropdown-panel-bg: var(--mantine-color-dark-7);
|
||||
--dropdown-panel-border: var(--mantine-color-dark-4);
|
||||
--dropdown-group-label: var(--mantine-color-dark-2);
|
||||
|
||||
--link-hover-bg: rgba(75, 119, 230, 0.06);
|
||||
--link-hover-border: rgba(75, 119, 230, 0.2);
|
||||
--link-hover-shadow:
|
||||
0 0 0 0.5px rgba(75, 119, 230, 0.1), 0 1px 3px rgba(75, 119, 230, 0.08);
|
||||
--link-focus-ring: rgba(75, 119, 230, 0.35);
|
||||
--link-toolbar-bg: rgba(21, 26, 36, 0.94);
|
||||
--link-toolbar-border: rgba(255, 255, 255, 0.1);
|
||||
--link-toolbar-shadow:
|
||||
0 12px 40px rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.3),
|
||||
0 0 0 0.5px rgba(75, 119, 230, 0.08);
|
||||
}
|
||||
|
||||
/* Dropzone drop state styling */
|
||||
[data-accept] .dropzone-inner {
|
||||
background-color: var(--landing-drop-inner-paper-bg) !important;
|
||||
@@ -717,7 +793,6 @@
|
||||
|
||||
/* Plan section card borders - only override in dark mode */
|
||||
[data-mantine-color-scheme="dark"] .plan-card {
|
||||
--paper-border-color: rgb(158, 158, 158) !important;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .plan-card [data-size="sm"] {
|
||||
@@ -739,6 +814,19 @@
|
||||
background-color: #1c598e !important;
|
||||
}
|
||||
|
||||
[data-color-scheme="midnight"]
|
||||
.plan-button:not(:disabled):not([data-disabled]) {
|
||||
background-color: #4b77e6 !important;
|
||||
}
|
||||
|
||||
/* Override Mantine's internal body/surface colors for midnight */
|
||||
[data-color-scheme="midnight"] {
|
||||
--mantine-color-body: #151a24;
|
||||
--mantine-color-dark-6: #1e2435;
|
||||
--mantine-color-dark-7: #151a24;
|
||||
--mantine-color-dark-9: #0f1318;
|
||||
}
|
||||
|
||||
/* Lighter grey for disabled plan buttons */
|
||||
.plan-button:disabled,
|
||||
.plan-button[data-disabled] {
|
||||
@@ -787,12 +875,14 @@
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Smooth transitions for theme switching */
|
||||
/* Smooth transitions for theme switching - backgrounds and borders only.
|
||||
Color (text) is intentionally excluded: Mantine applies the color scheme
|
||||
after the first paint, so a color transition would cause text to visibly
|
||||
crawl from the wrong colour on every page load. */
|
||||
* {
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
border-color 0.2s ease;
|
||||
}
|
||||
/* ── PDF Link Overlay (viewer) ── */
|
||||
:root {
|
||||
@@ -992,10 +1082,15 @@
|
||||
--shadow-color: rgba(15, 23, 42, 0.55);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
[data-theme="dark"],
|
||||
[data-color-scheme="dark"] {
|
||||
--shadow-color: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
[data-color-scheme="midnight"] {
|
||||
--shadow-color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
/* ---- Wordmark light/dark switching (use instead of JS colorScheme checks) ---- */
|
||||
.wordmark-dark-only {
|
||||
display: none;
|
||||
@@ -1099,3 +1194,17 @@
|
||||
animation-duration: 160ms;
|
||||
animation-timing-function: cubic-bezier(0.2, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Mantine animates a button's background-color and its text/icon colour on
|
||||
* separate transitions, so when a filled button flips state (selected ↔
|
||||
* unselected, enabled ↔ disabled) you briefly see e.g. a blue background
|
||||
* with the old dark-grey label still showing through. Drop the colour
|
||||
* tween — the swap is instant, but transforms/shadows/borders/opacity keep
|
||||
* animating for hover and press feedback. */
|
||||
.mantine-Button-root,
|
||||
.mantine-ActionIcon-root,
|
||||
.mantine-SegmentedControl-control,
|
||||
.mantine-SegmentedControl-label,
|
||||
.mantine-SegmentedControl-indicator {
|
||||
transition-property: transform, box-shadow, border-color, opacity !important;
|
||||
}
|
||||
|
||||
@@ -31,9 +31,9 @@ test.describe("Navigation", () => {
|
||||
await page.locator('a[href="/merge"]').first().click();
|
||||
await expect(page).toHaveURL(/\/merge/);
|
||||
|
||||
// In the redesigned UI, "Back to tools" button replaces the old "Tools" link
|
||||
// In the redesigned UI, "Back to all tools" button replaces the old "Tools" link
|
||||
await page
|
||||
.getByRole("button", { name: /Back to tools/i })
|
||||
.getByRole("button", { name: /Back to all tools/i })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page).toHaveURL("/");
|
||||
|
||||
@@ -36,10 +36,10 @@ test.describe("4. PDF Tool Pages - Common Patterns", () => {
|
||||
await page.goto("/compress");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
|
||||
// Step 2: Click the "Back to tools" button in ToolPanel to go back to /.
|
||||
// Step 2: Click the "Back to all tools" button in ToolPanel to go back to /.
|
||||
// In the redesigned UI this replaces the old "Tools" sidebar link.
|
||||
const homeLink = page
|
||||
.getByRole("button", { name: /Back to tools/i })
|
||||
.getByRole("button", { name: /Back to all tools/i })
|
||||
.first();
|
||||
await homeLink.click();
|
||||
|
||||
|
||||
@@ -53,6 +53,19 @@ const gray: MantineColorsTuple = [
|
||||
"var(--color-gray-900)",
|
||||
];
|
||||
|
||||
const dark: MantineColorsTuple = [
|
||||
"#c4c9d2",
|
||||
"#9aa3b0",
|
||||
"#9ca3af",
|
||||
"#6b7280",
|
||||
"#242c3a",
|
||||
"#161c24",
|
||||
"#0f1318",
|
||||
"#0a0d12",
|
||||
"#070a0e",
|
||||
"#040507",
|
||||
];
|
||||
|
||||
export const mantineTheme = createTheme({
|
||||
// Primary color
|
||||
primaryColor: "primary",
|
||||
@@ -63,6 +76,7 @@ export const mantineTheme = createTheme({
|
||||
green,
|
||||
yellow,
|
||||
gray,
|
||||
dark,
|
||||
},
|
||||
|
||||
// Spacing system - uses CSS variables
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface AppConfig {
|
||||
timestampDefaultTsaUrl?: string;
|
||||
timestampCustomTsaUrls?: string[];
|
||||
timestampTsaPresets?: { label: string; url: string }[];
|
||||
aiEngineEnabled?: boolean;
|
||||
}
|
||||
|
||||
export type AppConfigBootstrapMode = "blocking" | "non-blocking";
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { flushSync } from "react-dom";
|
||||
|
||||
type ViewTransitionDoc = Document & {
|
||||
startViewTransition?: (cb: () => void) => { finished: Promise<void> };
|
||||
};
|
||||
|
||||
/**
|
||||
* Run a state update inside a View Transition so the browser cross-fades
|
||||
* (and morphs any elements sharing a {@code view-transition-name}) between
|
||||
* the before/after DOMs.
|
||||
*
|
||||
* Falls back to a plain synchronous update when the API is unavailable
|
||||
* (Firefox <130, JSDOM, motion-reduced preference).
|
||||
*/
|
||||
export function withViewTransition(update: () => void): Promise<void> {
|
||||
if (typeof document === "undefined") {
|
||||
update();
|
||||
return Promise.resolve();
|
||||
}
|
||||
const doc = document as ViewTransitionDoc;
|
||||
if (doc.startViewTransition) {
|
||||
return doc.startViewTransition(() => flushSync(update)).finished;
|
||||
}
|
||||
update();
|
||||
return Promise.resolve();
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { CheckoutProvider } from "@app/contexts/CheckoutContext";
|
||||
import { UpgradeBannerInitializer } from "@app/components/shared/UpgradeBannerInitializer";
|
||||
import { ServerExperienceProvider } from "@app/contexts/ServerExperienceContext";
|
||||
import { UpdateSeatsProvider } from "@app/contexts/UpdateSeatsContext";
|
||||
import { ChatProvider } from "@app/components/chat/ChatContext";
|
||||
|
||||
export function AppProviders({
|
||||
children,
|
||||
@@ -25,7 +26,7 @@ export function AppProviders({
|
||||
<ServerExperienceProvider>
|
||||
<CheckoutProvider>
|
||||
<UpgradeBannerInitializer />
|
||||
{children}
|
||||
<ChatProvider>{children}</ChatProvider>
|
||||
</CheckoutProvider>
|
||||
</ServerExperienceProvider>
|
||||
</UpdateSeatsProvider>
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
/* ─── Chat takeover ─────────────────────────────────────────────────────── */
|
||||
|
||||
.agents-takeover {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-toolbar);
|
||||
z-index: 5;
|
||||
view-transition-name: agents-rail;
|
||||
}
|
||||
|
||||
/* ─── Sidebar agents section ─────────────────────────────────────────────── */
|
||||
|
||||
.agents-section {
|
||||
padding: 0.5rem 0.75rem 0.875rem;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-toolbar);
|
||||
flex-shrink: 0;
|
||||
view-transition-name: agents-rail;
|
||||
}
|
||||
|
||||
/* Hero button — real Stirling agent */
|
||||
.agent-button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 0.625rem;
|
||||
background: var(--mantine-color-body);
|
||||
transition:
|
||||
background 150ms ease-out,
|
||||
border-color 150ms ease-out;
|
||||
}
|
||||
|
||||
.agent-button:hover {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-gray-4) 60%,
|
||||
var(--border-subtle)
|
||||
);
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.agent-button__logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--mantine-color-blue-filled);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Coming-soon agent rows */
|
||||
.agents-sidebar-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
.agent-button--coming-soon {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 0.625rem;
|
||||
background: var(--mantine-color-body);
|
||||
cursor: not-allowed !important;
|
||||
opacity: 0.65;
|
||||
transition:
|
||||
opacity 180ms ease-out,
|
||||
transform 180ms ease-out,
|
||||
border-color 120ms ease-out;
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
.agent-button--coming-soon {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
}
|
||||
|
||||
.agent-button--coming-soon:hover {
|
||||
opacity: 0.85;
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-gray-4) 60%,
|
||||
var(--border-subtle)
|
||||
);
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.agent-button__icon-plain {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
color: var(--mantine-color-dimmed);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agents-view-all {
|
||||
all: unset;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.375rem;
|
||||
padding: 0.25rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--mantine-color-dimmed);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
border-radius: 0.375rem;
|
||||
transition: opacity 120ms ease-out;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.agents-view-all:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Dark mode — sidebar */
|
||||
[data-mantine-color-scheme="dark"] .agent-button--coming-soon {
|
||||
background: transparent;
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-dark-4) 60%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agent-button--coming-soon:hover {
|
||||
border-color: var(--mantine-color-dark-3);
|
||||
background: var(--mantine-color-dark-6);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agent-button {
|
||||
background: transparent;
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-dark-4) 60%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agent-button:hover {
|
||||
border-color: var(--mantine-color-dark-3);
|
||||
background: var(--mantine-color-dark-6);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agent-button__logo {
|
||||
color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled));
|
||||
}
|
||||
|
||||
/* ─── Collapsed-rail agent button ────────────────────────────────────────── */
|
||||
|
||||
.agents-collapsed-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
color: var(--mantine-color-blue-filled);
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease-out;
|
||||
}
|
||||
|
||||
.agents-collapsed-btn:hover {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
/* ─── Fullscreen hero card ───────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* .tool-panel__fullscreen-group--agents gives the gradient border via the
|
||||
* padding-box / border-box trick. We only override the padding-box with a
|
||||
* very light tint so the card doesn't read as a colourful surface.
|
||||
*/
|
||||
.agents-hero {
|
||||
padding: 1.25rem 1.5rem 1.5rem;
|
||||
border-radius: 1rem;
|
||||
background:
|
||||
linear-gradient(
|
||||
125deg,
|
||||
color-mix(in srgb, var(--mantine-color-blue-1) 35%, white) 0%,
|
||||
color-mix(in srgb, var(--mantine-color-violet-1) 45%, white) 50%,
|
||||
color-mix(in srgb, var(--mantine-color-pink-0) 35%, white) 100%
|
||||
)
|
||||
padding-box,
|
||||
linear-gradient(
|
||||
135deg,
|
||||
var(--mantine-color-blue-6) 0%,
|
||||
var(--mantine-color-violet-6) 45%,
|
||||
var(--mantine-color-pink-6) 100%
|
||||
)
|
||||
border-box;
|
||||
}
|
||||
|
||||
/* Body: left CTA + right grid */
|
||||
.agents-hero__body {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* ── Left CTA — plain content, only the button inside is interactive ── */
|
||||
.agents-hero__cta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
flex: 0 0 18rem;
|
||||
box-sizing: border-box;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.agents-hero__cta-logo {
|
||||
color: var(--mantine-color-blue-filled);
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
.agents-hero__cta-headline {
|
||||
font-size: 1.375rem;
|
||||
}
|
||||
|
||||
.agents-hero__cta-btn {
|
||||
all: unset;
|
||||
margin-top: 1.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.4375rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--mantine-color-blue-6);
|
||||
color: white;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
width: fit-content;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.agents-hero__cta-btn:hover {
|
||||
background: var(--mantine-color-blue-7);
|
||||
}
|
||||
|
||||
.agents-hero__cta-btn:focus-visible {
|
||||
outline: 2px solid var(--mantine-color-blue-5);
|
||||
outline-offset: 3px;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Right 2×3 grid — sizes to content, pushed to the right ── */
|
||||
.agents-hero__grid {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
display: grid;
|
||||
/* auto columns: each column = widest item in that column */
|
||||
grid-template-columns: repeat(2, auto);
|
||||
gap: 0.625rem;
|
||||
align-content: start;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
/* Solid white cards — no opacity so the white is opaque */
|
||||
.agents-hero__grid-item {
|
||||
all: unset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.875rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.07);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--mantine-color-body);
|
||||
cursor: not-allowed;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.agents-hero__grid-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--mantine-color-blue-5);
|
||||
}
|
||||
|
||||
.agents-hero__grid-body {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── Dark mode — fullscreen ── */
|
||||
[data-mantine-color-scheme="dark"] .agents-hero {
|
||||
background:
|
||||
linear-gradient(
|
||||
125deg,
|
||||
color-mix(in srgb, var(--mantine-color-blue-9) 55%, #1a1b2e) 0%,
|
||||
color-mix(in srgb, var(--mantine-color-violet-9) 45%, #1a1b2e) 50%,
|
||||
color-mix(in srgb, var(--mantine-color-pink-9) 40%, #1a1b2e) 100%
|
||||
)
|
||||
padding-box,
|
||||
linear-gradient(
|
||||
135deg,
|
||||
var(--mantine-color-blue-6) 0%,
|
||||
var(--mantine-color-violet-6) 45%,
|
||||
var(--mantine-color-pink-6) 100%
|
||||
)
|
||||
border-box;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agents-hero__cta-logo {
|
||||
color: var(--mantine-color-blue-3);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agents-hero__grid-item {
|
||||
background: var(--fullscreen-bg-item);
|
||||
border-color: var(--fullscreen-border-subtle-70);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agents-hero__grid-icon {
|
||||
color: var(--mantine-color-blue-3);
|
||||
}
|
||||
|
||||
/* ─── agents-rail view transition — slide up ─────────────────────────────── */
|
||||
|
||||
::view-transition-old(agents-rail) {
|
||||
animation: vt-agents-out 150ms ease-out forwards;
|
||||
}
|
||||
|
||||
::view-transition-new(agents-rail) {
|
||||
animation: vt-agents-in 260ms cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes vt-agents-out {
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes vt-agents-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-old(agents-rail),
|
||||
::view-transition-new(agents-rail) {
|
||||
animation-duration: 1ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useState } from "react";
|
||||
import type { ElementType } from "react";
|
||||
import { Box, Group, Text, UnstyledButton } from "@mantine/core";
|
||||
import TableChartRoundedIcon from "@mui/icons-material/TableChartRounded";
|
||||
import SummarizeRoundedIcon from "@mui/icons-material/SummarizeRounded";
|
||||
import VisibilityOffRoundedIcon from "@mui/icons-material/VisibilityOffRounded";
|
||||
import GavelRoundedIcon from "@mui/icons-material/GavelRounded";
|
||||
import AssignmentRoundedIcon from "@mui/icons-material/AssignmentRounded";
|
||||
import CodeRoundedIcon from "@mui/icons-material/CodeRounded";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useChat } from "@app/components/chat/ChatContext";
|
||||
import { ChatPanel } from "@app/components/chat/ChatPanel";
|
||||
import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
|
||||
import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline";
|
||||
import { withViewTransition } from "@app/utils/viewTransition";
|
||||
import "@app/components/agents/AgentsPanel.css";
|
||||
|
||||
interface ComingSoonAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
Icon: ElementType;
|
||||
}
|
||||
|
||||
const COMING_SOON_AGENTS: ComingSoonAgent[] = [
|
||||
{
|
||||
id: "data-extraction",
|
||||
name: "Data Extraction",
|
||||
description: "Extract tables & structured data",
|
||||
Icon: TableChartRoundedIcon,
|
||||
},
|
||||
{
|
||||
id: "doc-summary",
|
||||
name: "Summariser",
|
||||
description: "Summarise long documents",
|
||||
Icon: SummarizeRoundedIcon,
|
||||
},
|
||||
{
|
||||
id: "auto-redaction",
|
||||
name: "Auto Redaction",
|
||||
description: "Redact PII automatically",
|
||||
Icon: VisibilityOffRoundedIcon,
|
||||
},
|
||||
{
|
||||
id: "compliance",
|
||||
name: "Compliance Check",
|
||||
description: "Audit documents for compliance",
|
||||
Icon: GavelRoundedIcon,
|
||||
},
|
||||
{
|
||||
id: "form-filler",
|
||||
name: "Form Filler",
|
||||
description: "Fill PDF forms intelligently",
|
||||
Icon: AssignmentRoundedIcon,
|
||||
},
|
||||
{
|
||||
id: "pdf-to-markdown",
|
||||
name: "PDF to Markdown",
|
||||
description: "Convert PDFs to clean Markdown",
|
||||
Icon: CodeRoundedIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export function useAgentsEnabled(): boolean {
|
||||
const { config } = useAppConfig();
|
||||
return Boolean(config?.aiEngineEnabled);
|
||||
}
|
||||
|
||||
export function useAgentChatOpen(): boolean {
|
||||
const { isOpen } = useChat();
|
||||
return isOpen;
|
||||
}
|
||||
|
||||
const PREVIEW_COUNT = 3;
|
||||
|
||||
/** Sidebar agents section — Stirling as hero CTA, coming-soon agents below. */
|
||||
export function AgentsSection() {
|
||||
const { t } = useTranslation();
|
||||
const { isOpen, setOpen } = useChat();
|
||||
const enabled = useAgentsEnabled();
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
if (!enabled || isOpen) return null;
|
||||
|
||||
const comingSoonLabel = t("agents.coming_soon", "Coming soon");
|
||||
const visibleAgents = showAll
|
||||
? COMING_SOON_AGENTS
|
||||
: COMING_SOON_AGENTS.slice(0, PREVIEW_COUNT);
|
||||
|
||||
return (
|
||||
<Box className="agents-section" w="100%">
|
||||
{/* Main Stirling agent — real and clickable */}
|
||||
<UnstyledButton
|
||||
className="agent-button agent-button--hero"
|
||||
onClick={() => withViewTransition(() => setOpen(true))}
|
||||
aria-label={t("agents.stirling_name", "Stirling")}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" align="center">
|
||||
<Box className="agent-button__logo">
|
||||
<StirlingLogoOutline size={28} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{t("agents.stirling_name", "Stirling")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{t(
|
||||
"agents.stirling_description",
|
||||
"Your general-purpose PDF assistant",
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
{/* Coming-soon agents */}
|
||||
<div className="agents-sidebar-list">
|
||||
{visibleAgents.map(({ id, name, description, Icon }) => (
|
||||
<AppTooltip
|
||||
key={id}
|
||||
content={comingSoonLabel}
|
||||
position="left"
|
||||
arrow
|
||||
delay={0}
|
||||
>
|
||||
<UnstyledButton
|
||||
className="agent-button agent-button--coming-soon"
|
||||
aria-disabled="true"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" align="center">
|
||||
<Box className="agent-button__icon-plain">
|
||||
<Icon sx={{ fontSize: "1.1rem" }} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</AppTooltip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!showAll ? (
|
||||
<button
|
||||
type="button"
|
||||
className="agents-view-all"
|
||||
onClick={() => withViewTransition(() => setShowAll(true))}
|
||||
>
|
||||
{t("agents.view_all", "View all agents")} →
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="agents-view-all"
|
||||
onClick={() => withViewTransition(() => setShowAll(false))}
|
||||
>
|
||||
{t("agents.show_less", "Show less")} ↑
|
||||
</button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Icon-only agent button in the collapsed (minimised) right rail. */
|
||||
export function AgentsCollapsedButton({ onExpand }: { onExpand: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const { setOpen } = useChat();
|
||||
const enabled = useAgentsEnabled();
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
const label = t("agents.stirling_tooltip", "Stirling agent");
|
||||
|
||||
return (
|
||||
<AppTooltip content={label} position="left" arrow delay={300}>
|
||||
<UnstyledButton
|
||||
onClick={() => {
|
||||
onExpand();
|
||||
setOpen(true);
|
||||
}}
|
||||
aria-label={label}
|
||||
className="agents-collapsed-btn"
|
||||
>
|
||||
<StirlingLogoOutline size={22} />
|
||||
</UnstyledButton>
|
||||
</AppTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fullscreen hero card — Stirling CTA on the left, 2×3 coming-soon grid on
|
||||
* the right. Matches the gradient border of the other fullscreen category cards.
|
||||
*/
|
||||
export function AgentsFullscreenSection() {
|
||||
const { t } = useTranslation();
|
||||
const { isOpen, setOpen } = useChat();
|
||||
const enabled = useAgentsEnabled();
|
||||
|
||||
if (!enabled || isOpen) return null;
|
||||
|
||||
const comingSoonLabel = t("agents.coming_soon", "Coming soon");
|
||||
|
||||
return (
|
||||
<section
|
||||
className="agents-hero tool-panel__fullscreen-group--agents"
|
||||
aria-label={t("agents.section_title", "Agents")}
|
||||
>
|
||||
<div className="agents-hero__body">
|
||||
{/* Left: Stirling content — only the button is interactive */}
|
||||
<div className="agents-hero__cta">
|
||||
<div className="agents-hero__cta-logo">
|
||||
<StirlingLogoOutline size={36} />
|
||||
</div>
|
||||
<Text className="agents-hero__cta-headline" fw={700} lh={1.2} mt="xs">
|
||||
{t("agents.stirling_full_name", "Stirling General Agent")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt={8} lh={1.55}>
|
||||
{t(
|
||||
"agents.stirling_long_description",
|
||||
"General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents.",
|
||||
)}
|
||||
</Text>
|
||||
<button
|
||||
type="button"
|
||||
className="agents-hero__cta-btn"
|
||||
onClick={() => withViewTransition(() => setOpen(true))}
|
||||
>
|
||||
{t("agents.start_chat", "Start chatting")} →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right: 2×3 grid of coming-soon agents */}
|
||||
<div className="agents-hero__grid">
|
||||
{COMING_SOON_AGENTS.map(({ id, name, description, Icon }) => (
|
||||
<AppTooltip
|
||||
key={id}
|
||||
content={comingSoonLabel}
|
||||
position="top"
|
||||
arrow
|
||||
delay={0}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="agents-hero__grid-item"
|
||||
aria-disabled="true"
|
||||
>
|
||||
<span className="agents-hero__grid-icon">
|
||||
<Icon sx={{ fontSize: "1rem" }} />
|
||||
</span>
|
||||
<div className="agents-hero__grid-body">
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
</button>
|
||||
</AppTooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Full-rail chat overlay rendered inside ToolPanel. */
|
||||
export function AgentsChatOverlay() {
|
||||
const { t } = useTranslation();
|
||||
const { isOpen, setOpen } = useChat();
|
||||
const enabled = useAgentsEnabled();
|
||||
|
||||
if (!enabled || !isOpen) return null;
|
||||
|
||||
return (
|
||||
<Box className="agents-takeover">
|
||||
<ChatPanel
|
||||
onBack={() => withViewTransition(() => setOpen(false))}
|
||||
backLabel={t("agents.back_to_tools", "Back to tools")}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* A temp stirling logo, may change in future.
|
||||
*/
|
||||
export function StirlingLogoOutline({ size = 20 }: { size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 192 192"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M68.48 102.4 L184.73 6.45 L184.73 96.05 L68.48 192 Z"
|
||||
opacity="0.5"
|
||||
/>
|
||||
<path d="M7.26 95.83 L123.37 0 L123.37 89.5 L7.26 185.33 Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user