diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java
index 0d777d9481..c59c6f3721 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java
@@ -130,6 +130,7 @@ public class ControllerAuditAspect {
String previousPrincipal = MDC.get("auditPrincipal");
String previousOrigin = MDC.get("auditOrigin");
+ String previousSource = MDC.get("auditSource");
String previousIp = MDC.get("auditIp");
// EARLY CAPTURE: Capture from SecurityContext on request thread, store in MDC for async
@@ -161,6 +162,14 @@ public class ControllerAuditAspect {
return joinPoint.proceed();
}
+ // Stamp the free-UI source only for non-@Audited controller traffic — an actual
+ // tool / UI action. @Audited events (login, settings) return above without a source,
+ // so they never count as an "active editor" or a free UI run. The finally block
+ // restores auditSource, so a pooled thread can't leak a stale "WEB" into them.
+ if (previousSource == null) {
+ MDC.put("auditSource", auditService.captureCurrentSource());
+ }
+
long start = System.currentTimeMillis();
// Use auditService to create the base audit data
@@ -247,6 +256,7 @@ public class ControllerAuditAspect {
} finally {
restoreMdcValue("auditPrincipal", previousPrincipal);
restoreMdcValue("auditOrigin", previousOrigin);
+ restoreMdcValue("auditSource", previousSource);
restoreMdcValue("auditIp", previousIp);
}
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java
index bcb98aa04b..f83bf0afdc 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java
@@ -60,6 +60,8 @@ public class CustomAuditEventRepository implements AuditEventRepository {
clean.put("requestId", rid);
}
+ String source = MDC.get("auditSource");
+
String auditEventData = mapper.writeValueAsString(clean);
log.debug("AuditEvent data (JSON): {}", auditEventData);
@@ -67,6 +69,7 @@ public class CustomAuditEventRepository implements AuditEventRepository {
PersistentAuditEvent.builder()
.principal(safePrincipal(ev.getPrincipal()))
.type(ev.getType())
+ .source(source)
.data(auditEventData)
.timestamp(ev.getTimestamp())
.build();
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/FleetUsageController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/FleetUsageController.java
new file mode 100644
index 0000000000..57b74362ec
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/FleetUsageController.java
@@ -0,0 +1,71 @@
+package stirling.software.proprietary.controller.api;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.common.model.enumeration.Role;
+import stirling.software.proprietary.audit.AuditLevel;
+import stirling.software.proprietary.config.AuditConfigurationProperties;
+import stirling.software.proprietary.model.api.usage.FleetUsageStats;
+import stirling.software.proprietary.repository.PersistentAuditEventRepository;
+import stirling.software.proprietary.security.config.EnterpriseEndpoint;
+import stirling.software.proprietary.security.database.repository.UserRepository;
+
+/**
+ * Admin endpoint exposing free-editor fleet usage for the portal Usage card. Audit-derived figures
+ * (active editors, PDFs processed) are null (rendered as "N/A") rather than a misleading 0 whenever
+ * the data can't exist: the events they count (PDF_PROCESS, FILE_OPERATION, HTTP_REQUEST) are all
+ * STANDARD level, so a gate on {@code isEnabled()} alone would still return 0 at level=OFF/BASIC —
+ * we gate on {@code isLevelEnabled(STANDARD)} instead.
+ *
+ *
Known limitation: on a login-disabled self-hosted instance every request is anonymous, so its
+ * audit origin is SYSTEM (not WEB) and it is excluded from these WEB-only counts — active/PDFs then
+ * read 0 despite real usage. Historical audit rows written before the {@code source} column existed
+ * carry {@code source=null}, so the cumulative "PDFs edited" figure effectively starts at deploy.
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/usage")
+@PreAuthorize("hasRole('ADMIN')")
+@RequiredArgsConstructor
+@EnterpriseEndpoint
+public class FleetUsageController {
+
+ private final PersistentAuditEventRepository auditRepository;
+ private final UserRepository userRepository;
+ private final AuditConfigurationProperties auditConfig;
+
+ @GetMapping("/fleet-stats")
+ public FleetUsageStats fleetStats() {
+ // Exclude the reserved INTERNAL_API_USER row that InitialSecuritySetup creates on every
+ // install, so a fresh single-admin instance reads 1 editor, not 2.
+ Long deployed = userRepository.countByUsernameNot(Role.INTERNAL_API_USER.getRoleId());
+ // STANDARD is the level at which the counted events are recorded; below it the data
+ // can't exist, so report N/A instead of a 0 that would misrepresent an empty table.
+ boolean auditOn = auditConfig.isLevelEnabled(AuditLevel.STANDARD);
+ Instant since = Instant.now().minus(30, ChronoUnit.DAYS);
+ Long active =
+ auditOn
+ ? auditRepository.countDistinctPrincipalsBySourceExcludingTypeAfter(
+ "WEB", "UI_DATA", since)
+ : null;
+ Long pdfs =
+ auditOn
+ ? auditRepository.countByTypeInAndSourceAndTimestampAfter(
+ List.of("PDF_PROCESS", "FILE_OPERATION"), "WEB", Instant.EPOCH)
+ : null;
+ if (active != null && deployed != null && active > deployed) {
+ active = deployed; // active editors are a subset of those deployed
+ }
+ return new FleetUsageStats(deployed, active, pdfs);
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/usage/FleetUsageStats.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/usage/FleetUsageStats.java
new file mode 100644
index 0000000000..0e554fb884
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/usage/FleetUsageStats.java
@@ -0,0 +1,6 @@
+package stirling.software.proprietary.model.api.usage;
+
+/**
+ * Free-editor fleet usage for the portal Usage card. Null fields render as "N/A" (uncomputable).
+ */
+public record FleetUsageStats(Long editorsDeployed, Long activeThisMonth, Long pdfsProcessed) {}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/security/PersistentAuditEvent.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/security/PersistentAuditEvent.java
index 5d90926076..ccaf337c0b 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/model/security/PersistentAuditEvent.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/security/PersistentAuditEvent.java
@@ -18,7 +18,15 @@ import lombok.*;
columnList = "principal,type"),
@jakarta.persistence.Index(
name = "idx_audit_type_timestamp",
- columnList = "type,timestamp")
+ columnList = "type,timestamp"),
+ @jakarta.persistence.Index(
+ name = "idx_audit_type_source_timestamp",
+ columnList = "type,source,timestamp"),
+ // Leads with source (equality) for the active-editors query, which filters on
+ // source then a timestamp range and counts distinct principal.
+ @jakarta.persistence.Index(
+ name = "idx_audit_source_timestamp_principal",
+ columnList = "source,timestamp,principal")
})
@Data
@Builder
@@ -32,6 +40,7 @@ public class PersistentAuditEvent {
private String principal;
private String type;
+ private String source;
@Column(columnDefinition = "text")
private String data; // JSON blob
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java
index 27bca098b3..7c0081dd7c 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java
@@ -259,4 +259,21 @@ public interface PersistentAuditEventRepository extends JpaRepository :startDate")
List findAllExceptTypeAndTimestampAfterForExport(
@Param("excludeType") String excludeType, @Param("startDate") Instant startDate);
+
+ // Free-editor fleet usage: count genuine free-UI operations (source = "WEB") by type.
+ @Query(
+ "SELECT COUNT(e) FROM PersistentAuditEvent e "
+ + "WHERE e.type IN :types AND e.source = :source AND e.timestamp > :since")
+ long countByTypeInAndSourceAndTimestampAfter(
+ @Param("types") List types,
+ @Param("source") String source,
+ @Param("since") Instant since);
+
+ @Query(
+ "SELECT COUNT(DISTINCT e.principal) FROM PersistentAuditEvent e "
+ + "WHERE e.source = :source AND e.type <> :excludeType AND e.timestamp > :since")
+ long countDistinctPrincipalsBySourceExcludingTypeAfter(
+ @Param("source") String source,
+ @Param("excludeType") String excludeType,
+ @Param("since") Instant since);
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java
index 4e592e427a..acae6044de 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java
@@ -49,6 +49,9 @@ public interface UserRepository extends JpaRepository {
long countByTeam(Team team);
+ /** Count real users, excluding a reserved username such as the internal API user. */
+ long countByUsernameNot(String username);
+
List findAllByTeam(Team team);
// OAuth grandfathering queries
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java
index d86ae644e1..a56ece5285 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java
@@ -39,6 +39,7 @@ import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.RequestUriUtils;
+import stirling.software.proprietary.accountlink.BillableOperationClassifier;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
@@ -847,6 +848,39 @@ public class AuditService {
return origin;
}
+ /**
+ * Refines {@link #determineOrigin()} into an audit {@code source} that isolates genuine
+ * free-editor UI runs from automation/AI traffic that also arrives over the web channel.
+ *
+ * API and SYSTEM origins pass through unchanged. A WEB origin is demoted to "AUTOMATION" or
+ * "AI" when the request carries the automation marker or targets an AI surface; only a manual
+ * interactive tool call ({@code BYPASSED}) stays "WEB". A "WEB" source therefore counts as a
+ * free/BYPASSED UI run. The automation/AI resolution is delegated to {@link
+ * BillableOperationClassifier} so it can't drift from the billing gate's own signal.
+ *
+ *
IMPORTANT: like {@link #captureCurrentOrigin()} this must be called on the request thread
+ * before async execution, because it reads the current {@link HttpServletRequest}.
+ *
+ * @return "API", "SYSTEM", "AUTOMATION", "AI", or "WEB"
+ */
+ public String captureCurrentSource() {
+ String origin = determineOrigin();
+ if (!"WEB".equals(origin)) {
+ return origin;
+ }
+
+ HttpServletRequest req = getCurrentRequest();
+ if (req == null) {
+ return "WEB";
+ }
+ // apiKey=false: a WEB origin already means the request is not API-key authenticated.
+ return switch (BillableOperationClassifier.categorize(req, false)) {
+ case AUTOMATION -> "AUTOMATION";
+ case AI -> "AI";
+ default -> "WEB";
+ };
+ }
+
/**
* Determines the origin of the request: API (X-API-KEY), WEB (JWT), or SYSTEM (no auth).
* IMPORTANT: This must be called in the request thread before async execution.
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/config/CustomAuditEventRepositoryTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/config/CustomAuditEventRepositoryTest.java
index 3bdde8ecab..6ba15b24aa 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/config/CustomAuditEventRepositoryTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/config/CustomAuditEventRepositoryTest.java
@@ -3,12 +3,61 @@ package stirling.software.proprietary.config;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import java.time.Instant;
+import java.util.Map;
+
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.slf4j.MDC;
+import org.springframework.boot.actuate.audit.AuditEvent;
+
+import stirling.software.proprietary.model.security.PersistentAuditEvent;
+import stirling.software.proprietary.repository.PersistentAuditEventRepository;
+
+import tools.jackson.databind.json.JsonMapper;
class CustomAuditEventRepositoryTest {
+ @AfterEach
+ void clearMdc() {
+ MDC.clear();
+ }
+
+ @Test
+ void sourceIsPopulatedFromMdcAuditSource() {
+ PersistentAuditEventRepository repo = mock(PersistentAuditEventRepository.class);
+ CustomAuditEventRepository writer =
+ new CustomAuditEventRepository(repo, JsonMapper.builder().build());
+
+ MDC.put("auditSource", "WEB");
+ writer.add(new AuditEvent(Instant.now(), "admin", "PDF_PROCESS", Map.of("k", "v")));
+
+ ArgumentCaptor captor =
+ ArgumentCaptor.forClass(PersistentAuditEvent.class);
+ verify(repo).save(captor.capture());
+ assertEquals("WEB", captor.getValue().getSource());
+ }
+
+ @Test
+ void sourceIsNullWhenMdcAbsent() {
+ PersistentAuditEventRepository repo = mock(PersistentAuditEventRepository.class);
+ CustomAuditEventRepository writer =
+ new CustomAuditEventRepository(repo, JsonMapper.builder().build());
+
+ writer.add(new AuditEvent(Instant.now(), "admin", "PDF_PROCESS", Map.of("k", "v")));
+
+ ArgumentCaptor captor =
+ ArgumentCaptor.forClass(PersistentAuditEvent.class);
+ verify(repo).save(captor.capture());
+ assertNull(captor.getValue().getSource());
+ }
+
@Test
void shortPrincipalPassesThroughUnchanged() {
assertEquals(
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/FleetUsageControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/FleetUsageControllerTest.java
new file mode 100644
index 0000000000..11e168b8d1
--- /dev/null
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/FleetUsageControllerTest.java
@@ -0,0 +1,108 @@
+package stirling.software.proprietary.controller.api;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.time.Instant;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import stirling.software.common.model.enumeration.Role;
+import stirling.software.proprietary.audit.AuditLevel;
+import stirling.software.proprietary.config.AuditConfigurationProperties;
+import stirling.software.proprietary.model.api.usage.FleetUsageStats;
+import stirling.software.proprietary.repository.PersistentAuditEventRepository;
+import stirling.software.proprietary.security.database.repository.UserRepository;
+
+@ExtendWith(MockitoExtension.class)
+class FleetUsageControllerTest {
+
+ @Mock private PersistentAuditEventRepository auditRepository;
+ @Mock private UserRepository userRepository;
+ @Mock private AuditConfigurationProperties auditConfig;
+
+ private FleetUsageController controller;
+
+ @BeforeEach
+ void setUp() {
+ controller = new FleetUsageController(auditRepository, userRepository, auditConfig);
+ }
+
+ @Test
+ @DisplayName("deployed reflects the user count, excluding the internal API user")
+ void deployedFromUserCount() {
+ when(userRepository.countByUsernameNot(anyString())).thenReturn(7L);
+ when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(false);
+
+ FleetUsageStats stats = controller.fleetStats();
+
+ assertThat(stats.editorsDeployed()).isEqualTo(7L);
+ verify(userRepository).countByUsernameNot(Role.INTERNAL_API_USER.getRoleId());
+ }
+
+ @Test
+ @DisplayName("audit-derived figures are null when auditing is below STANDARD")
+ void auditOffYieldsNulls() {
+ when(userRepository.countByUsernameNot(anyString())).thenReturn(3L);
+ // Covers both disabled and the enabled-but-level=OFF/BASIC misconfig: isLevelEnabled
+ // is false, so no events can exist and we must report N/A, not a 0 from an empty table.
+ when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(false);
+
+ FleetUsageStats stats = controller.fleetStats();
+
+ assertThat(stats.activeThisMonth()).isNull();
+ assertThat(stats.pdfsProcessed()).isNull();
+ verify(auditRepository, never())
+ .countDistinctPrincipalsBySourceExcludingTypeAfter(
+ any(), any(), any(Instant.class));
+ verify(auditRepository, never())
+ .countByTypeInAndSourceAndTimestampAfter(anyList(), any(), any(Instant.class));
+ }
+
+ @Test
+ @DisplayName("audit-derived figures come from the repository when auditing is enabled")
+ void auditOnReadsRepository() {
+ when(userRepository.countByUsernameNot(anyString())).thenReturn(10L);
+ when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(true);
+ when(auditRepository.countDistinctPrincipalsBySourceExcludingTypeAfter(
+ eq("WEB"), eq("UI_DATA"), any(Instant.class)))
+ .thenReturn(4L);
+ when(auditRepository.countByTypeInAndSourceAndTimestampAfter(
+ anyList(), eq("WEB"), any(Instant.class)))
+ .thenReturn(1234L);
+
+ FleetUsageStats stats = controller.fleetStats();
+
+ assertThat(stats.editorsDeployed()).isEqualTo(10L);
+ assertThat(stats.activeThisMonth()).isEqualTo(4L);
+ assertThat(stats.pdfsProcessed()).isEqualTo(1234L);
+ }
+
+ @Test
+ @DisplayName("active editors are clamped to deployed (active is a subset)")
+ void activeClampedToDeployed() {
+ when(userRepository.countByUsernameNot(anyString())).thenReturn(2L);
+ when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(true);
+ when(auditRepository.countDistinctPrincipalsBySourceExcludingTypeAfter(
+ eq("WEB"), eq("UI_DATA"), any(Instant.class)))
+ .thenReturn(9L);
+ when(auditRepository.countByTypeInAndSourceAndTimestampAfter(
+ anyList(), eq("WEB"), any(Instant.class)))
+ .thenReturn(50L);
+
+ FleetUsageStats stats = controller.fleetStats();
+
+ assertThat(stats.activeThisMonth()).isEqualTo(2L);
+ }
+}
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditServiceTest.java
index 321d0975d4..a90fde6b85 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditServiceTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditServiceTest.java
@@ -35,6 +35,7 @@ import jakarta.servlet.http.HttpServletResponse;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
+import stirling.software.common.service.InternalApiClient;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
@@ -83,6 +84,61 @@ class AuditServiceTest {
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
}
+ @Nested
+ @DisplayName("captureCurrentSource()")
+ class CaptureSource {
+
+ @Test
+ @DisplayName("a plain authenticated web tool call is WEB")
+ void plainWebIsWeb() {
+ authenticateAs("alice");
+ bindRequest(new MockHttpServletRequest("POST", "/api/v1/general/merge-pdfs"));
+
+ assertThat(service.captureCurrentSource()).isEqualTo("WEB");
+ }
+
+ @Test
+ @DisplayName("the automation marker header demotes WEB to AUTOMATION")
+ void automationHeaderIsAutomation() {
+ authenticateAs("alice");
+ MockHttpServletRequest req =
+ new MockHttpServletRequest("POST", "/api/v1/general/merge");
+ req.addHeader(InternalApiClient.AUTOMATION_HEADER, "true");
+ bindRequest(req);
+
+ assertThat(service.captureCurrentSource()).isEqualTo("AUTOMATION");
+ }
+
+ @Test
+ @DisplayName("the AI surface demotes WEB to AI")
+ void aiSurfaceIsAi() {
+ authenticateAs("alice");
+ bindRequest(new MockHttpServletRequest("POST", "/api/v1/ai/tools/ask"));
+
+ assertThat(service.captureCurrentSource()).isEqualTo("AI");
+ }
+
+ @Test
+ @DisplayName("the AI surface is matched after stripping the deployment context path")
+ void aiSurfaceWithContextPathIsAi() {
+ authenticateAs("alice");
+ MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/tools/ask");
+ req.setContextPath("/stirling");
+ req.setRequestURI("/stirling/api/v1/ai/tools/ask");
+ bindRequest(req);
+
+ assertThat(service.captureCurrentSource()).isEqualTo("AI");
+ }
+
+ @Test
+ @DisplayName("an unauthenticated request is SYSTEM, not WEB")
+ void anonymousIsSystem() {
+ bindRequest(new MockHttpServletRequest("POST", "/api/v1/general/merge-pdfs"));
+
+ assertThat(service.captureCurrentSource()).isEqualTo("SYSTEM");
+ }
+ }
+
@Nested
@DisplayName("audit() gating")
class AuditGating {
diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml
index 233760298a..20fde0cc3f 100644
--- a/frontend/editor/public/locales/en-US/translation.toml
+++ b/frontend/editor/public/locales/en-US/translation.toml
@@ -6281,7 +6281,6 @@ cost = "Cost"
editorsDeployed = "Editors deployed"
inviteTeammates = "Invite teammates"
pdfsEdited = "PDFs edited"
-previewBadge = "Preview · sample data"
subtitle = "Deploy anywhere, for your whole team."
title = "Free PDF Editors"
diff --git a/frontend/editor/src/portal/api/fleetStats.ts b/frontend/editor/src/portal/api/fleetStats.ts
new file mode 100644
index 0000000000..b62526aacd
--- /dev/null
+++ b/frontend/editor/src/portal/api/fleetStats.ts
@@ -0,0 +1,24 @@
+import { apiClient } from "@portal/api/http";
+
+/**
+ * Free-editor fleet usage for the {@link FreePdfEditorsCard}.
+ *
+ * Self-hosted (this module) reads the local Stirling backend — the figures come
+ * from this instance's audit trail, filtered to free UI tool runs. A SaaS build
+ * shadows this module (src/saas/portal/api/fleetStats.ts) to read the
+ * team-scoped SaaS backend instead.
+ *
+ * Any field may be null when the backend can't compute it (e.g. EE auditing is
+ * disabled); the card renders null as "N/A" rather than a misleading 0.
+ */
+export interface FleetStats {
+ editorsDeployed: number | null;
+ activeThisMonth: number | null;
+ pdfsProcessed: number | null;
+}
+
+export function fetchFleetStats(signal?: AbortSignal): Promise {
+ return apiClient.local.json("/api/v1/usage/fleet-stats", {
+ signal,
+ });
+}
diff --git a/frontend/editor/src/portal/components/billing/FreePdfEditorsCard.stories.tsx b/frontend/editor/src/portal/components/billing/FreePdfEditorsCard.stories.tsx
index 442274431a..1b1a0f1d47 100644
--- a/frontend/editor/src/portal/components/billing/FreePdfEditorsCard.stories.tsx
+++ b/frontend/editor/src/portal/components/billing/FreePdfEditorsCard.stories.tsx
@@ -11,8 +11,8 @@ export default meta;
type Story = StoryObj;
/**
- * The team editor-fleet card. The metrics are SAMPLE data (flagged with the
- * Preview badge) until the fleet-telemetry endpoint lands in a follow-up PR;
- * "Invite teammates" is intentionally inert for now.
+ * The team editor-fleet card. Metrics come from GET /api/v1/usage/fleet-stats
+ * (audit-derived, free UI runs only); with no backend behind Storybook the
+ * fetch fails and the figures fall back to "N/A". Cost is always $0.
*/
export const Default: Story = {};
diff --git a/frontend/editor/src/portal/components/billing/FreePdfEditorsCard.tsx b/frontend/editor/src/portal/components/billing/FreePdfEditorsCard.tsx
index d06dfd23a6..76fa5a9109 100644
--- a/frontend/editor/src/portal/components/billing/FreePdfEditorsCard.tsx
+++ b/frontend/editor/src/portal/components/billing/FreePdfEditorsCard.tsx
@@ -1,26 +1,28 @@
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
-import { Button, Card, MetricCard, MetricStrip, StatusBadge } from "@app/ui";
+import { Button, Card, MetricCard, MetricStrip } from "@app/ui";
import GroupsIcon from "@mui/icons-material/GroupsRounded";
import PersonAddIcon from "@mui/icons-material/PersonAddAltRounded";
+import { useAsync } from "@portal/hooks/useAsync";
+import { fetchFleetStats } from "@portal/api/fleetStats";
/**
- * "Free PDF Editors" team-fleet card. The editors-deployed / active-this-month /
- * PDFs-edited figures come from a fleet-telemetry endpoint that does not exist
- * yet (tracked for a follow-up PR), so they are SAMPLE data — flagged with a
- * Preview badge — and "Invite teammates" is intentionally inert. The layout is
- * built so the page matches the marketing design; swap the constants for live
- * values when the endpoint lands.
+ * "Free PDF Editors" team-fleet card. Editors-deployed / active-this-month /
+ * PDFs-edited come from the instance's usage endpoint
+ * ({@code GET /api/v1/usage/fleet-stats}), derived from the audit trail filtered
+ * to free UI tool runs. A figure the backend can't compute (e.g. EE auditing is
+ * off) arrives as null and renders "N/A". Cost is always $0.
*/
-const SAMPLE = {
- editorsDeployed: "6",
- activeThisMonth: "4",
- pdfsEdited: "1,240",
-};
+function fmtMetric(value: number | null | undefined, loading: boolean): string {
+ if (loading) return "—";
+ if (value == null) return "N/A";
+ return value.toLocaleString();
+}
export function FreePdfEditorsCard() {
const navigate = useNavigate();
const { t } = useTranslation();
+ const { data, loading } = useAsync((signal) => fetchFleetStats(signal), []);
return (
@@ -30,13 +32,7 @@ export function FreePdfEditorsCard() {
- {t("portal.billing.freeEditors.title", "Free PDF Editors")}{" "}
-
- {t(
- "portal.billing.freeEditors.previewBadge",
- "Preview · sample data",
- )}
-
+ {t("portal.billing.freeEditors.title", "Free PDF Editors")}
{t(
@@ -52,18 +48,18 @@ export function FreePdfEditorsCard() {
"portal.billing.freeEditors.editorsDeployed",
"Editors deployed",
)}
- value={SAMPLE.editorsDeployed}
+ value={fmtMetric(data?.editorsDeployed, loading)}
/>