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 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
{t(
@@ -52,18 +48,18 @@ export function FreePdfEditorsCard() {
"portal.billing.freeEditors.editorsDeployed",
"Editors deployed",
)}
- value={SAMPLE.editorsDeployed}
+ value={fmtMetric(data?.editorsDeployed, loading)}
/>
- {t("portal.billing.freeEditors.title", "Free PDF Editors")}{" "}
-