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:
ConnorYoh
2026-07-08 14:42:44 +00:00
committed by GitHub
parent 18b0b19a67
commit 514b020f74
15 changed files with 412 additions and 27 deletions
@@ -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);
}
}
@@ -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();
@@ -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);
}
}
@@ -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) {}
@@ -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
@@ -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);
}
@@ -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.
@@ -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(
@@ -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);
}
}
@@ -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")}