mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Portal: real Free PDF Editors usage card (self-hosted) (#6919)
## 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.
This commit is contained in:
+10
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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();
|
||||
|
||||
+71
@@ -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.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
+6
@@ -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) {}
|
||||
+10
-1
@@ -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
|
||||
|
||||
+17
@@ -259,4 +259,21 @@ public interface PersistentAuditEventRepository extends JpaRepository<Persistent
|
||||
"SELECT e FROM PersistentAuditEvent e WHERE e.type != :excludeType AND e.timestamp > :startDate")
|
||||
List<PersistentAuditEvent> 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<String> 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);
|
||||
}
|
||||
|
||||
+3
@@ -49,6 +49,9 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
|
||||
long countByTeam(Team team);
|
||||
|
||||
/** Count real users, excluding a reserved username such as the internal API user. */
|
||||
long countByUsernameNot(String username);
|
||||
|
||||
List<User> findAllByTeam(Team team);
|
||||
|
||||
// OAuth grandfathering queries
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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.
|
||||
|
||||
+49
@@ -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<PersistentAuditEvent> 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<PersistentAuditEvent> captor =
|
||||
ArgumentCaptor.forClass(PersistentAuditEvent.class);
|
||||
verify(repo).save(captor.capture());
|
||||
assertNull(captor.getValue().getSource());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shortPrincipalPassesThroughUnchanged() {
|
||||
assertEquals(
|
||||
|
||||
+108
@@ -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);
|
||||
}
|
||||
}
|
||||
+56
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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<FleetStats> {
|
||||
return apiClient.local.json<FleetStats>("/api/v1/usage/fleet-stats", {
|
||||
signal,
|
||||
});
|
||||
}
|
||||
@@ -11,8 +11,8 @@ export default meta;
|
||||
type Story = StoryObj<typeof FreePdfEditorsCard>;
|
||||
|
||||
/**
|
||||
* 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 = {};
|
||||
|
||||
@@ -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 (
|
||||
<Card padding="loose">
|
||||
<div className="portal-billing__fleet-row">
|
||||
@@ -30,13 +32,7 @@ export function FreePdfEditorsCard() {
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="portal-billing__section-title">
|
||||
{t("portal.billing.freeEditors.title", "Free PDF Editors")}{" "}
|
||||
<StatusBadge tone="warning" size="sm" showDot={false}>
|
||||
{t(
|
||||
"portal.billing.freeEditors.previewBadge",
|
||||
"Preview · sample data",
|
||||
)}
|
||||
</StatusBadge>
|
||||
{t("portal.billing.freeEditors.title", "Free PDF Editors")}
|
||||
</h3>
|
||||
<p className="portal-billing__section-sub">
|
||||
{t(
|
||||
@@ -52,18 +48,18 @@ export function FreePdfEditorsCard() {
|
||||
"portal.billing.freeEditors.editorsDeployed",
|
||||
"Editors deployed",
|
||||
)}
|
||||
value={SAMPLE.editorsDeployed}
|
||||
value={fmtMetric(data?.editorsDeployed, loading)}
|
||||
/>
|
||||
<MetricCard
|
||||
label={t(
|
||||
"portal.billing.freeEditors.activeThisMonth",
|
||||
"Active this month",
|
||||
)}
|
||||
value={SAMPLE.activeThisMonth}
|
||||
value={fmtMetric(data?.activeThisMonth, loading)}
|
||||
/>
|
||||
<MetricCard
|
||||
label={t("portal.billing.freeEditors.pdfsEdited", "PDFs edited")}
|
||||
value={SAMPLE.pdfsEdited}
|
||||
value={fmtMetric(data?.pdfsProcessed, loading)}
|
||||
/>
|
||||
<MetricCard
|
||||
label={t("portal.billing.freeEditors.cost", "Cost")}
|
||||
|
||||
Reference in New Issue
Block a user