Compare commits

..
Author SHA1 Message Date
EthanHealy01 600add1474 run frontend fix 2026-05-27 12:42:22 +01:00
EthanHealy01 8451b06d53 chore: set aiEngine env vars automatically in dev:all
When AIENGINE_URL is provided (i.e. task dev:all), also set
AIENGINE_ENABLED=true and AIENGINE_TIMEOUTSECONDS=120 so the backend
picks up the full AI engine config without needing it in settings.yml.
2026-05-27 12:12:42 +01:00
EthanHealy01 7bb3024dc9 feat: add midnight theme and fix theme locations
Adds midnight dark theme, fixes broken theme variable locations, adds
clean-unused-theme-vars script, and updates RainbowThemeProvider and
related theme constants.
2026-05-27 11:37:49 +01:00
EthanHealy01 18cd2baec0 feat: add Agents UI to proprietary right sidebar
Adds agents panel to the right sidebar, gated behind aiEngineEnabled.
Includes RightSidebar refactor, FullscreenToolPanel, core stubs, and
proprietary AgentsPanel with Stirling hero CTA and coming-soon agents.
2026-05-27 11:14:19 +01:00
111 changed files with 5146 additions and 5890 deletions
+3 -2
View File
@@ -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/**/*
+2 -2
View File
@@ -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:
+15
View File
@@ -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
View File
@@ -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}}'
-3
View File
@@ -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();
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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);
}
}
}
@@ -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);
}
}
}
@@ -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());
}
}
}
@@ -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) {}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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());
}
}
@@ -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();
}
}
}
@@ -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 &lt;&lt; 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()
+ ")");
}
}
@@ -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());
}
}
@@ -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());
}
}
@@ -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());
}
}
@@ -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);
}
}
@@ -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"));
}
}
@@ -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)) {
@@ -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);
}
}
@@ -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:
-235
View File
@@ -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}"
-108
View File
@@ -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.
-25
View File
@@ -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",
]
}
@@ -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.
-642
View File
@@ -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
}
-6
View File
@@ -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 }}
-91
View File
@@ -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": ""
}
-95
View File
@@ -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 []
-194
View File
@@ -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:
-27
View File
@@ -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;
}
}
}
-33
View File
@@ -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;
}
}
}
-1
View File
@@ -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 -1
View File
@@ -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(() => {
+3 -3
View File
@@ -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}
+328 -219
View File
@@ -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