From 514b020f747b420341740876800cfa3e25a947f1 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:42:44 +0100 Subject: [PATCH] Portal: real Free PDF Editors usage card (self-hosted) (#6919) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Replaces the **mocked** "Free PDF Editors" fleet card on the portal Usage page with live figures. Cost stays a literal `$0`; any figure that can't be computed renders **N/A** (never a misleading 0). | Metric | Self-hosted source | |---|---| | **Editors deployed** | total users (`UserRepository.count()`) | | **Active this month** | distinct `source=WEB` principals active in 30d (excl. `UI_DATA` polling), clamped ≤ deployed | | **PDFs edited** | cumulative `PDF_PROCESS` + `FILE_OPERATION` audit events that are **free UI runs** | ## Why the counting approach "Free operations = UI tool runs." Two dead ends first: - **Billing/PAYG is the wrong source** — it *deliberately discards* free ops (classified `BYPASSED`, no DB row); its tables only hold billable (API/AI/automation). - **Raw audit is also wrong** — a tool controller emits `PDF_PROCESS` for UI **and** API/AI/automation calls, and billable traffic exists on every tier. So the count is **audit filtered to free UI runs**. Audit events gain a `source` column, stamped from the always-on signal `BillingCategoryClassifier.classify(...) == BYPASSED` (not API-key auth, no `X-Stirling-Automation` header, not `/api/v1/ai/`) — zero billing-module coupling. Captured on the request thread (`AuditService.captureCurrentSource`), carried via MDC in `ControllerAuditAspect` (same propagation as `requestId`), persisted by `CustomAuditEventRepository`. The count filters `source = 'WEB'`. ## Endpoint `GET /api/v1/usage/fleet-stats` — admin-gated, EE-only. Returns `null` per field when EE auditing is off (→ N/A). ## Frontend - New `portal/api/fleetStats.ts` → `apiClient.local` (this instance's backend). - `FreePdfEditorsCard` rewired to `useAsync(fetchFleetStats)`; preview badge removed, `null`→"N/A", loading→"—". ## Tests `:proprietary:build` green — `FleetUsageControllerTest` (4) and `CustomAuditEventRepositoryTest` (+2 for source-from-MDC) pass; spotless clean. ## Notes / follow-ups - `deployed` currently counts all users incl. disabled — refine to enabled-only later. - **SaaS** (team-scoped endpoint + a `fleetStats.ts` override) is deferred to a follow-up riding the portal-SaaS layering PR #6900. - Depends on EE auditing running at `AuditLevel ≥ STANDARD` for the audit-derived figures; otherwise they show N/A. --- .../audit/ControllerAuditAspect.java | 10 ++ .../config/CustomAuditEventRepository.java | 3 + .../controller/api/FleetUsageController.java | 71 ++++++++++++ .../model/api/usage/FleetUsageStats.java | 6 + .../model/security/PersistentAuditEvent.java | 11 +- .../PersistentAuditEventRepository.java | 17 +++ .../database/repository/UserRepository.java | 3 + .../proprietary/service/AuditService.java | 34 ++++++ .../CustomAuditEventRepositoryTest.java | 49 ++++++++ .../api/FleetUsageControllerTest.java | 108 ++++++++++++++++++ .../proprietary/service/AuditServiceTest.java | 56 +++++++++ .../public/locales/en-US/translation.toml | 1 - frontend/editor/src/portal/api/fleetStats.ts | 24 ++++ .../billing/FreePdfEditorsCard.stories.tsx | 6 +- .../components/billing/FreePdfEditorsCard.tsx | 40 +++---- 15 files changed, 412 insertions(+), 27 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/controller/api/FleetUsageController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/usage/FleetUsageStats.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/controller/api/FleetUsageControllerTest.java create mode 100644 frontend/editor/src/portal/api/fleetStats.ts 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)} />