From 02b04a238c2622edb22b3dbc4d84842da4b3def4 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Fri, 4 Sep 2026 00:15:39 +0200 Subject: [PATCH 1/4] feat: add an admin statistics dashboard Closes #351, which asked for a deployment statistics dashboard in the admin section with a CSV export. Two of its three asks had already been met in the meantime - the report endpoint gained a JSON variant, and the webui now ships @mui/x-charts, so the d3js suggestion is moot - leaving the auth change, the missing current month, and the page itself. Session-authenticated endpoints rather than a change to /admin/report. That endpoint takes its token as a request parameter and is therefore listed in SecurityConfig's permitAll block, which is exactly why a logged-in browser session can't use it; changing it would break anyone scripting the CSV export today. /admin/statistics and /admin/statistics/csv serve the same data through the session, the way every other endpoint under /admin/ does, and need no SecurityConfig change because /admin/** already requires ROLE_ADMIN. The CSV lives on its own path rather than behind content negotiation so the download can be a plain link - a browser navigation can't set an Accept header - and sets Content-Disposition so it saves under a sensible name. The month in progress is now computed on request. #235 specified that and it was never built: getAdminStatistics rejected the current month as "in the future" and 404'd anything unarchived, so a dashboard would have shown nothing until a month had elapsed, and nothing at all on a fresh deployment. The computation moves out of the archival job into AdminStatisticsService so both paths cannot drift, and the on-the-fly result is deliberately not saved - it is a partial month, and the job will archive the complete figure on the first of the following month. A past month with no row stays a 404: every figure but downloads is a snapshot of the registry as it was, so it cannot be reconstructed later. The page reads that endpoint a month at a time, with headline figures, the two numeric breakdowns as bar charts, and the four "top ten" lists as ranked tables linking to the extensions and namespaces they name. It follows the existing usage-stats chart for its @mui/x-charts usage. Months are navigated one at a time, forward stops at the month in progress, and an unarchived month is explained rather than reported as a failure. Note the existing "Usage Stats" page is per-customer rate-limit usage and unrelated to this. Two existing tests asserted the current month was rejected as future; they now assert it is computed, which is the behaviour change #235 called for. The archival job's computation tests move to AdminStatisticsServiceTest along with the logic they cover. Closes #351 Co-Authored-By: Claude Opus 5 (1M context) --- .../org/eclipse/openvsx/admin/AdminAPI.java | 71 +++++ .../eclipse/openvsx/admin/AdminService.java | 33 +- .../AdminStatisticsJobRequestHandler.java | 48 +-- .../openvsx/admin/AdminStatisticsService.java | 51 +++- .../eclipse/openvsx/admin/AdminAPITest.java | 168 +++++++++- .../AdminStatisticsJobRequestHandlerTest.java | 147 +-------- .../admin/AdminStatisticsServiceTest.java | 186 ++++++++++++ webui/src/extension-registry-service.ts | 42 ++- webui/src/extension-registry-types.ts | 25 ++ .../admin-dashboard/admin-dashboard-routes.ts | 1 + .../pages/admin-dashboard/admin-dashboard.tsx | 9 + .../admin-dashboard/statistics/statistics.tsx | 287 ++++++++++++++++++ .../statistics/use-admin-statistics.ts | 38 +++ .../pages/admin-dashboard/statistics.spec.tsx | 143 +++++++++ 14 files changed, 1050 insertions(+), 199 deletions(-) create mode 100644 server/src/test/java/org/eclipse/openvsx/admin/AdminStatisticsServiceTest.java create mode 100644 webui/src/pages/admin-dashboard/statistics/statistics.tsx create mode 100644 webui/src/pages/admin-dashboard/statistics/use-admin-statistics.ts create mode 100644 webui/test/unit/pages/admin-dashboard/statistics.spec.tsx diff --git a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java index 86810157b..50b454ff2 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java @@ -26,6 +26,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.util.Streamable; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -164,6 +165,76 @@ private AdminStatistics getReport(String tokenValue, int year, int month) { return admins.getAdminStatistics(year, month); } + /** + * Session-authenticated counterpart to {@code /admin/report}, for the admin dashboard. + *

+ * {@code /admin/report} takes an access token as a request parameter and is therefore listed in + * SecurityConfig's permitAll block, which is why it can't simply be reused from a logged-in + * browser session. It stays as it is - scripts depend on it - and this serves the same data the + * way every other endpoint under {@code /admin/} does, through the session. + */ + @GetMapping( + path = "/statistics", + produces = MediaType.APPLICATION_JSON_VALUE + ) + @Operation(hidden = true, summary = "Get the admin statistics for the given month and year") + @ApiResponse( + responseCode = "200", + description = "The statistics are returned in JSON format", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = AdminStatisticsJson.class) + ) + ) + @ApiResponse( + responseCode = "400", + description = "The year or month is invalid, or lies in the future", + content = @Content(schema = @Schema(implementation = AdminStatisticsJson.class)) + ) + @ApiResponse( + responseCode = "404", + description = "No statistics were archived for the given month", + content = @Content() + ) + public ResponseEntity getStatistics( + @RequestParam("year") int year, + @RequestParam("month") int month + ) { + try { + admins.checkAdminUser(); + return ResponseEntity.ok(admins.getAdminStatistics(year, month).toJson()); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(AdminStatisticsJson.class); + } + } + + /** + * The same data as CSV, on its own path rather than by content negotiation so the dashboard's + * download can be a plain link - a browser navigation can't set an Accept header. The + * Content-Disposition names the file, which a bare string response wouldn't. + */ + @GetMapping( + path = "/statistics/csv", + produces = "text/csv" + ) + @Operation(hidden = true, summary = "Get the admin statistics for the given month and year as CSV") + @ApiResponse(responseCode = "200", description = "The statistics are returned as CSV") + public ResponseEntity getStatisticsCsv( + @RequestParam("year") int year, + @RequestParam("month") int month + ) { + try { + admins.checkAdminUser(); + var csv = admins.getAdminStatistics(year, month).toCsv(); + var fileName = String.format("openvsx-statistics-%d-%02d.csv", year, month); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\"") + .body(csv); + } catch (ErrorResultException exc) { + return ResponseEntity.status(exc.getStatus()).body(exc.getMessage()); + } + } + @GetMapping( path = "/stats", produces = MediaType.APPLICATION_JSON_VALUE diff --git a/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java b/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java index 8e1b95bd3..a6d6cdfa6 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminService.java @@ -94,6 +94,7 @@ public class AdminService { private final JobRequestScheduler scheduler; private final MailService mail; private final LogService logs; + private final AdminStatisticsService statistics; public AdminService( RepositoryService repositories, @@ -108,7 +109,8 @@ public AdminService( CacheService cache, JobRequestScheduler scheduler, MailService mail, - LogService logs + LogService logs, + AdminStatisticsService statistics ) { this.repositories = repositories; this.extensions = extensions; @@ -123,6 +125,7 @@ public AdminService( this.scheduler = scheduler; this.mail = mail; this.logs = logs; + this.statistics = statistics; } @EventListener @@ -719,12 +722,28 @@ private UserData.Role parseRole(String role) { public AdminStatistics getAdminStatistics(int year, int month) throws ErrorResultException { validateYearAndMonth(year, month); - var statistics = repositories.findAdminStatisticsByYearAndMonth(year, month); - if (statistics == null) { - throw new NotFoundException(); + var archived = repositories.findAdminStatisticsByYearAndMonth(year, month); + if (archived != null) { + return archived; + } + + // The archival job only runs on the first of the following month, so the month in progress + // never has a stored row. Computing it here is what #235 described and what makes a + // dashboard useful today rather than a month from now. Not saved: it is a partial month, + // and the job will archive the complete figure in its own time. + if (isCurrentMonth(year, month)) { + return statistics.computeAdminStatistics(year, month); } - return statistics; + // A past month with no row was never archived - the job did not run then, and it cannot be + // reconstructed after the fact, because every figure but downloads is a snapshot of the + // registry as it was. + throw new NotFoundException(); + } + + private boolean isCurrentMonth(int year, int month) { + var now = TimeUtil.getCurrentUTC(); + return year == now.getYear() && month == now.getMonthValue(); } private void validateYearAndMonth(int year, int month) { @@ -735,8 +754,10 @@ private void validateYearAndMonth(int year, int month) { throw new ErrorResultException("Month must be a value between 1 and 12", HttpStatus.BAD_REQUEST); } + // The month in progress is allowed: it is served on the fly (see getAdminStatistics). Only + // a month that hasn't started yet is rejected. var now = TimeUtil.getCurrentUTC(); - if (year > now.getYear() || (year == now.getYear() && month >= now.getMonthValue())) { + if (year > now.getYear() || (year == now.getYear() && month > now.getMonthValue())) { throw new ErrorResultException("Combination of year and month lies in the future", HttpStatus.BAD_REQUEST); } } diff --git a/server/src/main/java/org/eclipse/openvsx/admin/AdminStatisticsJobRequestHandler.java b/server/src/main/java/org/eclipse/openvsx/admin/AdminStatisticsJobRequestHandler.java index aae6ded2a..9d41f7d1a 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminStatisticsJobRequestHandler.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminStatisticsJobRequestHandler.java @@ -9,69 +9,25 @@ * ****************************************************************************** */ package org.eclipse.openvsx.admin; -import java.time.LocalDateTime; - import org.jobrunr.jobs.lambdas.JobRequestHandler; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; -import org.eclipse.openvsx.entities.AdminStatistics; -import org.eclipse.openvsx.repositories.RepositoryService; - @Component public class AdminStatisticsJobRequestHandler implements JobRequestHandler { private static final Logger LOGGER = LoggerFactory.getLogger(AdminStatisticsJobRequestHandler.class); - private final RepositoryService repositories; private final AdminStatisticsService service; - public AdminStatisticsJobRequestHandler(RepositoryService repositories, AdminStatisticsService service) { - this.repositories = repositories; + public AdminStatisticsJobRequestHandler(AdminStatisticsService service) { this.service = service; } @Override public void run(AdminStatisticsJobRequest jobRequest) throws Exception { - var year = jobRequest.getYear(); - var month = jobRequest.getMonth(); - - var extensions = repositories.countActiveExtensions(); - var downloadsTotal = repositories.downloadsTotal(); - - var lastDate = LocalDateTime.of(year, month, 1, 0, 0).minusMonths(1); - var lastAdminStatistics = repositories - .findAdminStatisticsByYearAndMonth(lastDate.getYear(), lastDate.getMonthValue()); - var lastDownloadsTotal = lastAdminStatistics != null ? lastAdminStatistics.getDownloadsTotal() : 0; - var downloads = downloadsTotal - lastDownloadsTotal; - var publishers = repositories.countActiveExtensionPublishers(); - var averageReviewsPerExtension = repositories.averageNumberOfActiveReviewsPerActiveExtension(); - var namespaceOwners = repositories.countPublishersThatClaimedNamespaceOwnership(); - var extensionsByRating = repositories.countActiveExtensionsGroupedByExtensionReviewRating(); - var publishersByExtensionsPublished = repositories.countActiveExtensionPublishersGroupedByExtensionsPublished(); - - var limit = 10; - var topMostActivePublishingUsers = repositories.topMostActivePublishingUsers(limit); - var topNamespaceExtensions = repositories.topNamespaceExtensions(limit); - var topNamespaceExtensionVersions = repositories.topNamespaceExtensionVersions(limit); - var topMostDownloadedExtensions = repositories.topMostDownloadedExtensions(limit); - - var statistics = new AdminStatistics(); - statistics.setYear(year); - statistics.setMonth(month); - statistics.setExtensions(extensions); - statistics.setDownloads(downloads); - statistics.setDownloadsTotal(downloadsTotal); - statistics.setPublishers(publishers); - statistics.setAverageReviewsPerExtension(averageReviewsPerExtension); - statistics.setNamespaceOwners(namespaceOwners); - statistics.setExtensionsByRating(extensionsByRating); - statistics.setPublishersByExtensionsPublished(publishersByExtensionsPublished); - statistics.setTopMostActivePublishingUsers(topMostActivePublishingUsers); - statistics.setTopNamespaceExtensions(topNamespaceExtensions); - statistics.setTopNamespaceExtensionVersions(topNamespaceExtensionVersions); - statistics.setTopMostDownloadedExtensions(topMostDownloadedExtensions); + var statistics = service.computeAdminStatistics(jobRequest.getYear(), jobRequest.getMonth()); service.saveAdminStatistics(statistics); } } diff --git a/server/src/main/java/org/eclipse/openvsx/admin/AdminStatisticsService.java b/server/src/main/java/org/eclipse/openvsx/admin/AdminStatisticsService.java index 8748261fb..5b9aaf075 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminStatisticsService.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminStatisticsService.java @@ -9,19 +9,68 @@ * ****************************************************************************** */ package org.eclipse.openvsx.admin; +import java.time.LocalDateTime; + import jakarta.persistence.EntityManager; import jakarta.transaction.Transactional; import org.springframework.stereotype.Component; import org.eclipse.openvsx.entities.AdminStatistics; +import org.eclipse.openvsx.repositories.RepositoryService; @Component public class AdminStatisticsService { + /** How many entries the "top ..." breakdowns carry. */ + private static final int TOP_LIMIT = 10; + private final EntityManager entityManager; + private final RepositoryService repositories; - public AdminStatisticsService(EntityManager entityManager) { + public AdminStatisticsService(EntityManager entityManager, RepositoryService repositories) { this.entityManager = entityManager; + this.repositories = repositories; + } + + /** + * Computes the statistics for the given month from the registry's current state, without saving + * them. + *

+ * Every figure except {@code downloads} is a point-in-time snapshot rather than an aggregate + * over the month: the archival job runs on the first of the following month, so a stored row is + * the state shortly after that month ended. {@code downloads} is the one exception, derived as + * the growth in {@code downloadsTotal} since the previous month's row - which means that + * without a previous row (the first month a registry archives, and for the on-the-fly current + * month on a registry that has none yet) it reports every download ever rather than the + * month's. That is pre-existing behaviour of the archival job, kept here so the on-the-fly and + * archived paths cannot disagree. + */ + public AdminStatistics computeAdminStatistics(int year, int month) { + var extensions = repositories.countActiveExtensions(); + var downloadsTotal = repositories.downloadsTotal(); + + var lastDate = LocalDateTime.of(year, month, 1, 0, 0).minusMonths(1); + var lastAdminStatistics = repositories + .findAdminStatisticsByYearAndMonth(lastDate.getYear(), lastDate.getMonthValue()); + var lastDownloadsTotal = lastAdminStatistics != null ? lastAdminStatistics.getDownloadsTotal() : 0; + + var statistics = new AdminStatistics(); + statistics.setYear(year); + statistics.setMonth(month); + statistics.setExtensions(extensions); + statistics.setDownloads(downloadsTotal - lastDownloadsTotal); + statistics.setDownloadsTotal(downloadsTotal); + statistics.setPublishers(repositories.countActiveExtensionPublishers()); + statistics.setAverageReviewsPerExtension(repositories.averageNumberOfActiveReviewsPerActiveExtension()); + statistics.setNamespaceOwners(repositories.countPublishersThatClaimedNamespaceOwnership()); + statistics.setExtensionsByRating(repositories.countActiveExtensionsGroupedByExtensionReviewRating()); + statistics.setPublishersByExtensionsPublished( + repositories.countActiveExtensionPublishersGroupedByExtensionsPublished()); + statistics.setTopMostActivePublishingUsers(repositories.topMostActivePublishingUsers(TOP_LIMIT)); + statistics.setTopNamespaceExtensions(repositories.topNamespaceExtensions(TOP_LIMIT)); + statistics.setTopNamespaceExtensionVersions(repositories.topNamespaceExtensionVersions(TOP_LIMIT)); + statistics.setTopMostDownloadedExtensions(repositories.topMostDownloadedExtensions(TOP_LIMIT)); + return statistics; } @Transactional diff --git a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java index 6fe026925..4256eb931 100644 --- a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java @@ -123,6 +123,10 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.hamcrest.Matchers.containsString; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.never; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -178,6 +182,11 @@ void noTrustedPublishersByDefault() { @MockitoBean ExtensionVersionIntegrityService integrityService; + // Only the on-the-fly current month reaches this; every other statistics path reads an + // archived row through the mocked RepositoryService above. + @MockitoBean + AdminStatisticsService adminStatisticsService; + @Autowired MockMvc mockMvc; @@ -1807,34 +1816,165 @@ void testArchivedReportJson() throws Exception { }))); } + // The month in progress has no archived row - the job only runs on the first of the following + // month - so it is computed on demand instead of being rejected as "in the future", which is + // what #235 specified and what makes the dashboard in #351 useful before a month has elapsed. @Test void testCurrentMonthAdminReportCsv() throws Exception { var token = mockAdminToken(); var now = LocalDateTime.now(); + var year = now.getYear(); + var month = now.getMonthValue(); + when(adminStatisticsService.computeAdminStatistics(year, month)) + .thenReturn(currentMonthStatistics(year, month)); + mockMvc.perform( - get( - "/admin/report?token={token}&year={year}&month={month}", - token.getValue(), - now.getYear(), - now.getMonthValue()) + get("/admin/report?token={token}&year={year}&month={month}", token.getValue(), year, month) .header(HttpHeaders.ACCEPT, "text/csv")) - .andExpect(status().isBadRequest()) - .andExpect(content().string("Combination of year and month lies in the future")); + .andExpect(status().isOk()) + .andExpect(content().string(containsString(year + "," + month + ",1234,423,67890"))); } @Test void testCurrentMonthAdminReportJson() throws Exception { var token = mockAdminToken(); var now = LocalDateTime.now(); + var year = now.getYear(); + var month = now.getMonthValue(); + when(adminStatisticsService.computeAdminStatistics(year, month)) + .thenReturn(currentMonthStatistics(year, month)); + + mockMvc.perform( + get("/admin/report?token={token}&year={year}&month={month}", token.getValue(), year, month) + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.year").value(year)) + .andExpect(jsonPath("$.month").value(month)) + .andExpect(jsonPath("$.extensions").value(1234)) + .andExpect(jsonPath("$.downloads").value(423)); + } + + // A month that has ended without an archived row can't be reconstructed - every figure but + // downloads is a snapshot of the registry as it was - so it stays a 404 rather than silently + // reporting today's numbers under a past month's heading. + @Test + void testPastMonthWithoutArchivedReportIsNotFound() throws Exception { + var token = mockAdminToken(); + var past = LocalDateTime.now().minusMonths(2); + when(repositories.findAdminStatisticsByYearAndMonth(past.getYear(), past.getMonthValue())).thenReturn(null); + mockMvc.perform( get( "/admin/report?token={token}&year={year}&month={month}", token.getValue(), - now.getYear(), - now.getMonthValue()) + past.getYear(), + past.getMonthValue()) .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)) - .andExpect(status().isBadRequest()) - .andExpect(content().json(errorJson("Combination of year and month lies in the future"))); + .andExpect(status().isNotFound()); + Mockito.verify(adminStatisticsService, never()).computeAdminStatistics(anyInt(), anyInt()); + } + + // The session-authenticated counterpart to /admin/report, which the dashboard uses because + // /admin/report takes its token as a request parameter and is therefore permitAll in + // SecurityConfig - unusable from a logged-in browser session. + @Test + void testStatisticsForAnArchivedMonth() throws Exception { + mockAdminUser(); + var past = LocalDateTime.now().minusMonths(1); + var year = past.getYear(); + var month = past.getMonthValue(); + when(repositories.findAdminStatisticsByYearAndMonth(year, month)) + .thenReturn(currentMonthStatistics(year, month)); + + mockMvc.perform( + get("/admin/statistics?year={year}&month={month}", year, month) + .with(user("admin_user").authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))) + .with(csrf().asHeader())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.year").value(year)) + .andExpect(jsonPath("$.extensions").value(1234)); + } + + @Test + void testStatisticsComputesTheCurrentMonth() throws Exception { + mockAdminUser(); + var now = LocalDateTime.now(); + var year = now.getYear(); + var month = now.getMonthValue(); + when(adminStatisticsService.computeAdminStatistics(year, month)) + .thenReturn(currentMonthStatistics(year, month)); + + mockMvc.perform( + get("/admin/statistics?year={year}&month={month}", year, month) + .with(user("admin_user").authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))) + .with(csrf().asHeader())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.month").value(month)); + Mockito.verify(adminStatisticsService).computeAdminStatistics(year, month); + } + + @Test + void testStatisticsNotAdmin() throws Exception { + mockNormalUser(); + var past = LocalDateTime.now().minusMonths(1); + mockMvc.perform( + get("/admin/statistics?year={year}&month={month}", past.getYear(), past.getMonthValue()) + .with(user("test_user")) + .with(csrf().asHeader())) + .andExpect(status().isForbidden()); + } + + // The download is a plain link from the dashboard, so it needs its own path (a navigation can't + // set an Accept header) and a filename for the browser to save it under. + @Test + void testStatisticsCsvIsAnAttachment() throws Exception { + mockAdminUser(); + var past = LocalDateTime.now().minusMonths(1); + var year = past.getYear(); + var month = past.getMonthValue(); + when(repositories.findAdminStatisticsByYearAndMonth(year, month)) + .thenReturn(currentMonthStatistics(year, month)); + + mockMvc.perform( + get("/admin/statistics/csv?year={year}&month={month}", year, month) + .with(user("admin_user").authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))) + .with(csrf().asHeader())) + .andExpect(status().isOk()) + .andExpect( + header().string( + HttpHeaders.CONTENT_DISPOSITION, + String.format("attachment; filename=\"openvsx-statistics-%d-%02d.csv\"", year, month))) + .andExpect(content().string(containsString("year,month,extensions"))); + } + + @Test + void testStatisticsCsvNotAdmin() throws Exception { + mockNormalUser(); + var past = LocalDateTime.now().minusMonths(1); + mockMvc.perform( + get("/admin/statistics/csv?year={year}&month={month}", past.getYear(), past.getMonthValue()) + .with(user("test_user")) + .with(csrf().asHeader())) + .andExpect(status().isForbidden()); + } + + private AdminStatistics currentMonthStatistics(int year, int month) { + var stats = new AdminStatistics(); + stats.setYear(year); + stats.setMonth(month); + stats.setExtensions(1234); + stats.setDownloads(423); + stats.setDownloadsTotal(67890); + stats.setPublishers(891); + stats.setAverageReviewsPerExtension(4.5); + stats.setNamespaceOwners(56); + stats.setExtensionsByRating(Map.of(5, 136)); + stats.setPublishersByExtensionsPublished(Map.of(1, 670)); + stats.setTopMostActivePublishingUsers(Map.of("u_foo", 93)); + stats.setTopNamespaceExtensions(Map.of("n_foo", 9)); + stats.setTopNamespaceExtensionVersions(Map.of("nv_foo", 234)); + stats.setTopMostDownloadedExtensions(Map.of("foo.bar", 3847L)); + return stats; } @Test @@ -2694,7 +2834,8 @@ AdminService adminService( CacheService cache, JobRequestScheduler scheduler, MailService mail, - LogService logs + LogService logs, + AdminStatisticsService statistics ) { return new AdminService( repositories, @@ -2709,7 +2850,8 @@ AdminService adminService( cache, scheduler, mail, - logs); + logs, + statistics); } @Bean diff --git a/server/src/test/java/org/eclipse/openvsx/admin/AdminStatisticsJobRequestHandlerTest.java b/server/src/test/java/org/eclipse/openvsx/admin/AdminStatisticsJobRequestHandlerTest.java index 18d3b3813..47423588e 100644 --- a/server/src/test/java/org/eclipse/openvsx/admin/AdminStatisticsJobRequestHandlerTest.java +++ b/server/src/test/java/org/eclipse/openvsx/admin/AdminStatisticsJobRequestHandlerTest.java @@ -9,152 +9,35 @@ * ****************************************************************************** */ package org.eclipse.openvsx.admin; -import java.util.Map; - import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; import org.mockito.Mockito; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Bean; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.mockito.junit.jupiter.MockitoExtension; import org.eclipse.openvsx.entities.AdminStatistics; -import org.eclipse.openvsx.repositories.RepositoryService; -@ExtendWith(SpringExtension.class) +/** + * The handler is now only the archival half: the computation it used to carry inline lives in + * {@link AdminStatisticsService} and is covered by {@link AdminStatisticsServiceTest}. + */ +@ExtendWith(MockitoExtension.class) class AdminStatisticsJobRequestHandlerTest { - @MockitoBean - RepositoryService repositories; - - @MockitoBean + @Mock AdminStatisticsService service; - @Autowired + @InjectMocks AdminStatisticsJobRequestHandler handler; @Test - void testAdminStatisticsJobRequestHandler() throws Exception { - var expectedStatistics = mockAdminStatistics(); - - var request = new AdminStatisticsJobRequest(2023, 11); - handler.run(request); - Mockito.verify(service).saveAdminStatistics(expectedStatistics); - } - - @Test - void testAdminStatisticsJobRequestHandlerWithPreviousStatistics() throws Exception { - var expectedStatistics = mockAdminStatistics(); - expectedStatistics.setDownloads(678L); - - var prevStatistics = new AdminStatistics(); - prevStatistics.setDownloadsTotal(5000); - Mockito.when(repositories.findAdminStatisticsByYearAndMonth(2023, 10)).thenReturn(prevStatistics); - - var request = new AdminStatisticsJobRequest(2023, 11); - handler.run(request); - Mockito.verify(service).saveAdminStatistics(expectedStatistics); - } - - @TestConfiguration - static class TestConfig { - @Bean - AdminStatisticsJobRequestHandler adminStatisticsJobRequestHandler( - RepositoryService repositories, - AdminStatisticsService service - ) { - return new AdminStatisticsJobRequestHandler(repositories, service); - } - } - - private AdminStatistics mockAdminStatistics() { - var year = 2023; - var month = 11; - var extensions = 1234L; - var downloadsTotal = 5678L; - var publishers = 579L; - var averageReviewsPerExtension = 2.5; - var namespaceOwners = 268L; - var extensionsByRating = Map.of( - 1, - 34, - 2, - 100, - 3, - 700, - 4, - 150, - 5, - 250); - var publishersByExtensionsPublished = Map.of( - 1, - 500, - 3, - 70, - 10, - 9); - var topMostActivePublishingUsers = Map.of( - "foo", - 400, - "bar", - 150, - "baz", - 29); - var topNamespaceExtensions = Map.of( - "lorum", - 800, - "ipsum", - 400, - "dolar", - 34); - var topNamespaceExtensionVersions = Map.of( - "lorum", - 8000, - "ipsum", - 2000, - "dolar", - 68); - var topMostDownloadedExtensions = Map.of( - "lorum.alpha", - 1200L, - "ipsum.beta", - 450L, - "dolar.omega", - 300L); - - var expectedStatistics = new AdminStatistics(); - expectedStatistics.setYear(year); - expectedStatistics.setMonth(month); - expectedStatistics.setExtensions(extensions); - expectedStatistics.setDownloads(downloadsTotal); - expectedStatistics.setDownloadsTotal(downloadsTotal); - expectedStatistics.setPublishers(publishers); - expectedStatistics.setAverageReviewsPerExtension(averageReviewsPerExtension); - expectedStatistics.setNamespaceOwners(namespaceOwners); - expectedStatistics.setExtensionsByRating(extensionsByRating); - expectedStatistics.setPublishersByExtensionsPublished(publishersByExtensionsPublished); - expectedStatistics.setTopMostActivePublishingUsers(topMostActivePublishingUsers); - expectedStatistics.setTopNamespaceExtensions(topNamespaceExtensions); - expectedStatistics.setTopNamespaceExtensionVersions(topNamespaceExtensionVersions); - expectedStatistics.setTopMostDownloadedExtensions(topMostDownloadedExtensions); + void archivesTheComputedStatisticsForTheRequestedMonth() throws Exception { + var statistics = new AdminStatistics(); + Mockito.when(service.computeAdminStatistics(2023, 11)).thenReturn(statistics); - Mockito.when(repositories.countActiveExtensions()).thenReturn(extensions); - Mockito.when(repositories.downloadsTotal()).thenReturn(downloadsTotal); - Mockito.when(repositories.countActiveExtensionPublishers()).thenReturn(publishers); - Mockito.when(repositories.averageNumberOfActiveReviewsPerActiveExtension()) - .thenReturn(averageReviewsPerExtension); - Mockito.when(repositories.countPublishersThatClaimedNamespaceOwnership()).thenReturn(namespaceOwners); - Mockito.when(repositories.countActiveExtensionsGroupedByExtensionReviewRating()).thenReturn(extensionsByRating); - Mockito.when(repositories.countActiveExtensionPublishersGroupedByExtensionsPublished()) - .thenReturn(publishersByExtensionsPublished); - var limit = 10; - Mockito.when(repositories.topMostActivePublishingUsers(limit)).thenReturn(topMostActivePublishingUsers); - Mockito.when(repositories.topNamespaceExtensions(limit)).thenReturn(topNamespaceExtensions); - Mockito.when(repositories.topNamespaceExtensionVersions(limit)).thenReturn(topNamespaceExtensionVersions); - Mockito.when(repositories.topMostDownloadedExtensions(limit)).thenReturn(topMostDownloadedExtensions); + handler.run(new AdminStatisticsJobRequest(2023, 11)); - return expectedStatistics; + Mockito.verify(service).saveAdminStatistics(statistics); } } diff --git a/server/src/test/java/org/eclipse/openvsx/admin/AdminStatisticsServiceTest.java b/server/src/test/java/org/eclipse/openvsx/admin/AdminStatisticsServiceTest.java new file mode 100644 index 000000000..2b39b331e --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/admin/AdminStatisticsServiceTest.java @@ -0,0 +1,186 @@ +/** ****************************************************************************** + * Copyright (c) 2023 Precies. Software Ltd and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + * ****************************************************************************** */ +package org.eclipse.openvsx.admin; + +import java.util.Map; + +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.eclipse.openvsx.entities.AdminStatistics; +import org.eclipse.openvsx.repositories.RepositoryService; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The statistics computation, which used to live inline in {@link AdminStatisticsJobRequestHandler} + * and moved here so the archival job and the on-the-fly current month (see + * {@code AdminService#getAdminStatistics}) cannot compute them differently. + */ +@ExtendWith(MockitoExtension.class) +class AdminStatisticsServiceTest { + + @Mock + EntityManager entityManager; + + @Mock + RepositoryService repositories; + + AdminStatisticsService service; + + @BeforeEach + void setUp() { + service = new AdminStatisticsService(entityManager, repositories); + } + + @Test + void computesEveryFigureFromTheCurrentState() { + var expectedStatistics = mockAdminStatistics(); + + var statistics = service.computeAdminStatistics(2023, 11); + + assertThat(statistics).isEqualTo(expectedStatistics); + } + + // downloads is the one figure that isn't a snapshot: it's the growth in downloadsTotal since + // the previous month's row. + @Test + void derivesTheMonthsDownloadsFromThePreviousMonth() { + var expectedStatistics = mockAdminStatistics(); + expectedStatistics.setDownloads(678L); + + var prevStatistics = new AdminStatistics(); + prevStatistics.setDownloadsTotal(5000); + Mockito.when(repositories.findAdminStatisticsByYearAndMonth(2023, 10)).thenReturn(prevStatistics); + + var statistics = service.computeAdminStatistics(2023, 11); + + assertThat(statistics.getDownloads()).isEqualTo(678L); + assertThat(statistics).isEqualTo(expectedStatistics); + } + + // Without a previous row there is nothing to subtract, so the month reports every download the + // registry has ever served. Pre-existing behaviour of the archival job, pinned here because the + // on-the-fly path now hits it too on a registry that has never archived a month. + @Test + void reportsEveryDownloadWhenNoPreviousMonthWasArchived() { + mockAdminStatistics(); + Mockito.when(repositories.findAdminStatisticsByYearAndMonth(2023, 10)).thenReturn(null); + + var statistics = service.computeAdminStatistics(2023, 11); + + assertThat(statistics.getDownloads()).isEqualTo(statistics.getDownloadsTotal()); + } + + // The month asked for is what the row is labelled with, not the month the computation runs in. + @Test + void labelsTheStatisticsWithTheRequestedMonth() { + mockAdminStatistics(); + + var statistics = service.computeAdminStatistics(2023, 11); + + assertThat(statistics.getYear()).isEqualTo(2023); + assertThat(statistics.getMonth()).isEqualTo(11); + } + + private AdminStatistics mockAdminStatistics() { + var year = 2023; + var month = 11; + var extensions = 1234L; + var downloadsTotal = 5678L; + var publishers = 579L; + var averageReviewsPerExtension = 2.5; + var namespaceOwners = 268L; + var extensionsByRating = Map.of( + 1, + 34, + 2, + 100, + 3, + 700, + 4, + 150, + 5, + 250); + var publishersByExtensionsPublished = Map.of( + 1, + 500, + 3, + 70, + 10, + 9); + var topMostActivePublishingUsers = Map.of( + "foo", + 400, + "bar", + 150, + "baz", + 29); + var topNamespaceExtensions = Map.of( + "lorum", + 800, + "ipsum", + 400, + "dolar", + 34); + var topNamespaceExtensionVersions = Map.of( + "lorum", + 8000, + "ipsum", + 2000, + "dolar", + 68); + var topMostDownloadedExtensions = Map.of( + "lorum.alpha", + 1200L, + "ipsum.beta", + 450L, + "dolar.omega", + 300L); + + var expectedStatistics = new AdminStatistics(); + expectedStatistics.setYear(year); + expectedStatistics.setMonth(month); + expectedStatistics.setExtensions(extensions); + expectedStatistics.setDownloads(downloadsTotal); + expectedStatistics.setDownloadsTotal(downloadsTotal); + expectedStatistics.setPublishers(publishers); + expectedStatistics.setAverageReviewsPerExtension(averageReviewsPerExtension); + expectedStatistics.setNamespaceOwners(namespaceOwners); + expectedStatistics.setExtensionsByRating(extensionsByRating); + expectedStatistics.setPublishersByExtensionsPublished(publishersByExtensionsPublished); + expectedStatistics.setTopMostActivePublishingUsers(topMostActivePublishingUsers); + expectedStatistics.setTopNamespaceExtensions(topNamespaceExtensions); + expectedStatistics.setTopNamespaceExtensionVersions(topNamespaceExtensionVersions); + expectedStatistics.setTopMostDownloadedExtensions(topMostDownloadedExtensions); + + Mockito.when(repositories.countActiveExtensions()).thenReturn(extensions); + Mockito.when(repositories.downloadsTotal()).thenReturn(downloadsTotal); + Mockito.when(repositories.countActiveExtensionPublishers()).thenReturn(publishers); + Mockito.when(repositories.averageNumberOfActiveReviewsPerActiveExtension()) + .thenReturn(averageReviewsPerExtension); + Mockito.when(repositories.countPublishersThatClaimedNamespaceOwnership()).thenReturn(namespaceOwners); + Mockito.when(repositories.countActiveExtensionsGroupedByExtensionReviewRating()).thenReturn(extensionsByRating); + Mockito.when(repositories.countActiveExtensionPublishersGroupedByExtensionsPublished()) + .thenReturn(publishersByExtensionsPublished); + var limit = 10; + Mockito.when(repositories.topMostActivePublishingUsers(limit)).thenReturn(topMostActivePublishingUsers); + Mockito.when(repositories.topNamespaceExtensions(limit)).thenReturn(topNamespaceExtensions); + Mockito.when(repositories.topNamespaceExtensionVersions(limit)).thenReturn(topNamespaceExtensionVersions); + Mockito.when(repositories.topMostDownloadedExtensions(limit)).thenReturn(topMostDownloadedExtensions); + + return expectedStatistics; + } +} diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index 5c8edd5d5..08222cdc3 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -58,7 +58,8 @@ import { TrustedPublisherStatus, ConsistencyCheckList, ConsistencyFindingList, - SearchIndex + SearchIndex, + AdminStatistics } from './extension-registry-types'; import { createAbsoluteURL, addQuery } from './utils'; import { sendRequest, ErrorResponse, sendNonRetriableRequest, sendStrictRequest } from './server-request'; @@ -772,6 +773,12 @@ export interface AdminService { getSettings(abortController: AbortController): Promise>; updateSettings(settings: Settings): Promise>; getSearchIndex(abortController: AbortController): Promise>; + getAdminStatistics( + abortController: AbortController, + year: number, + month: number + ): Promise>; + getAdminStatisticsCsvUrl(year: number, month: number): string; updateSearchIndex(): Promise>; getConsistencyChecks(abortController: AbortController): Promise>; getConsistencyFindings( @@ -1577,6 +1584,39 @@ export class AdminServiceImpl implements AdminService { }); } + async getAdminStatistics( + abortController: AbortController, + year: number, + month: number + ): Promise> { + return sendNonRetriableRequest({ + abortController, + credentials: true, + endpoint: createAbsoluteURL( + [this.registry.serverUrl, 'admin', 'statistics'], + [ + { key: 'year', value: year }, + { key: 'month', value: month } + ] + ) + }); + } + + /** + * The CSV export is a URL rather than a fetch: the download is a plain link, so the browser + * saves the file under the name the server's Content-Disposition gives it instead of the page + * having to build a blob. + */ + getAdminStatisticsCsvUrl(year: number, month: number): string { + return createAbsoluteURL( + [this.registry.serverUrl, 'admin', 'statistics', 'csv'], + [ + { key: 'year', value: year }, + { key: 'month', value: month } + ] + ); + } + async getSearchIndex(abortController: AbortController): Promise> { return sendNonRetriableRequest({ abortController, diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index 5b4edfe2a..b3ae36b0b 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -63,6 +63,31 @@ export interface SearchEntry { deprecated: boolean; } +/** + * Registry-wide statistics for one month, as archived by the monthly job or computed on the fly for + * the month in progress. Mirrors the server's `AdminStatisticsJson`. + * + * Every figure except `downloads` is a point-in-time snapshot rather than a total over the month; + * `downloads` is the growth in `downloadsTotal` since the previous month. + */ +export interface AdminStatistics { + year: number; + month: number; + extensions: number; + downloads: number; + downloadsTotal: number; + publishers: number; + averageReviewsPerExtension: number; + namespaceOwners: number; + extensionsByRating: { rating: number; extensions: number }[]; + publishersByExtensionsPublished: { extensionsPublished: number; publishers: number }[]; + topMostActivePublishingUsers: { userLoginName: string; publishedExtensionVersions: number }[]; + topNamespaceExtensions: { namespace: string; extensions: number }[]; + topNamespaceExtensionVersions: { namespace: string; extensionVersions: number }[]; + topMostDownloadedExtensions: { extensionIdentifier: string; downloads: number }[]; + error?: string; +} + export const VERSION_ALIASES = ['latest', 'pre-release']; export interface Extension { diff --git a/webui/src/pages/admin-dashboard/admin-dashboard-routes.ts b/webui/src/pages/admin-dashboard/admin-dashboard-routes.ts index 4667e2051..efad2177f 100644 --- a/webui/src/pages/admin-dashboard/admin-dashboard-routes.ts +++ b/webui/src/pages/admin-dashboard/admin-dashboard-routes.ts @@ -13,6 +13,7 @@ import { createRoute } from '../../utils'; export namespace AdminDashboardRoutes { export const ROOT = 'admin-dashboard'; export const MAIN = createRoute([ROOT]); + export const STATISTICS = createRoute([ROOT, 'statistics']); export const NAMESPACE_ADMIN = createRoute([ROOT, 'namespaces']); export const EXTENSION_ADMIN = createRoute([ROOT, 'extensions']); export const PUBLISHER_ADMIN = createRoute([ROOT, 'publisher']); diff --git a/webui/src/pages/admin-dashboard/admin-dashboard.tsx b/webui/src/pages/admin-dashboard/admin-dashboard.tsx index d3b523f4f..f261fa8f9 100644 --- a/webui/src/pages/admin-dashboard/admin-dashboard.tsx +++ b/webui/src/pages/admin-dashboard/admin-dashboard.tsx @@ -14,6 +14,7 @@ import { styled } from '@mui/material/styles'; import { Route, Routes, useNavigate } from 'react-router'; import AccountBoxIcon from '@mui/icons-material/AccountBox'; import AssignmentIndIcon from '@mui/icons-material/AssignmentInd'; +import AssessmentIcon from '@mui/icons-material/Assessment'; import BarChartIcon from '@mui/icons-material/BarChart'; import ExtensionSharpIcon from '@mui/icons-material/ExtensionSharp'; import FactCheckIcon from '@mui/icons-material/FactCheck'; @@ -47,8 +48,15 @@ const ExtensionAdmin = lazy(() => import('./extension-admin').then(m => ({ defau const UsageStatsView = lazy(() => import('./usage-stats/usage-stats').then(m => ({ default: m.UsageStatsView }))); const DataConsistency = lazy(() => import('./consistency/consistency').then(m => ({ default: m.DataConsistency }))); const SearchIndexAdmin = lazy(() => import('./search-index/search-index').then(m => ({ default: m.SearchIndexAdmin }))); +const StatisticsAdmin = lazy(() => import('./statistics/statistics').then(m => ({ default: m.StatisticsAdmin }))); const navConfig: NavEntry[] = [ + { + path: AdminDashboardRoutes.STATISTICS, + name: 'Statistics', + icon: , + description: 'Registry statistics per month, with a CSV export' + }, { path: AdminDashboardRoutes.NAMESPACE_ADMIN, name: 'Namespaces', @@ -246,6 +254,7 @@ export const AdminDashboard: FunctionComponent = props => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/webui/src/pages/admin-dashboard/statistics/statistics.tsx b/webui/src/pages/admin-dashboard/statistics/statistics.tsx new file mode 100644 index 000000000..618267cd4 --- /dev/null +++ b/webui/src/pages/admin-dashboard/statistics/statistics.tsx @@ -0,0 +1,287 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { FunctionComponent, ReactNode, useContext, useMemo, useState } from 'react'; +import { + Alert, + Box, + Button, + CircularProgress, + IconButton, + Link, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Typography +} from '@mui/material'; +import { BarPlot, ChartsContainer, ChartsTooltip, ChartsXAxis, ChartsYAxis } from '@mui/x-charts'; +import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import DownloadIcon from '@mui/icons-material/Download'; +import { DateTime } from 'luxon'; +import { MainContext } from '../../../context'; +import type { AdminStatistics } from '../../../extension-registry-types'; +import { useAdminStatistics } from './use-admin-statistics'; + +const numberFormat = Intl.NumberFormat('en-US'); +const compactFormat = Intl.NumberFormat('en-US', { notation: 'compact' }); + +/** A headline figure, laid out so a row of them reads as one band. */ +const StatCard: FunctionComponent<{ label: string; value: string; hint?: string }> = ({ label, value, hint }) => ( + + + {label} + + + {value} + + {hint ? ( + + {hint} + + ) : null} + +); + +/** A bar chart over a small labelled series, which is the shape of every breakdown here. */ +const BreakdownChart: FunctionComponent<{ + title: string; + labels: string[]; + values: number[]; + seriesLabel: string; +}> = ({ title, labels, values, seriesLabel }) => { + if (labels.length === 0) { + return null; + } + return ( + + + {title} + + compactFormat.format(value), + width: 55 + } + ]}> + + + + + + + ); +}; + +/** A ranked table, for the breakdowns whose labels are identifiers rather than small numbers. */ +const RankedTable: FunctionComponent<{ + title: string; + valueHeader: string; + rows: { label: string; value: number; href?: string }[]; +}> = ({ title, valueHeader, rows }) => { + if (rows.length === 0) { + return null; + } + return ( + + + {title} + + + + + Name + {valueHeader} + + + + {rows.map(row => ( + + + {row.href ? ( + + {row.label} + + ) : ( + row.label + )} + + {numberFormat.format(row.value)} + + ))} + +
+
+ ); +}; + +export const StatisticsAdmin: FunctionComponent = () => { + const { service } = useContext(MainContext); + // UTC throughout, because that is the zone the archival job labels its rows in. + const currentMonth = useMemo(() => DateTime.utc().startOf('month'), []); + const [month, setMonth] = useState(currentMonth); + + const isCurrentMonth = month.hasSame(currentMonth, 'month') && month.hasSame(currentMonth, 'year'); + const { data, isFetching, error } = useAdminStatistics(month.year, month.month); + + const heading = month.toFormat('LLLL yyyy'); + const csvUrl = service.admin.getAdminStatisticsCsvUrl(month.year, month.month); + + return ( + + + Statistics + + + + + setMonth(month.minus({ months: 1 }))}> + + + + {heading} + + setMonth(month.plus({ months: 1 }))}> + + + + + + {isCurrentMonth ? ( + + This month is still in progress and is calculated on request. Completed months are archived on + the first of the following month. + + ) : null} + + + {isFetching && !data ? : null} + {error && !isFetching ? : null} + {data ? : null} + + ); +}; + +/** + * A month with no data is the normal state for any month that ended before the registry was + * deployed, or one where the archival job did not run - not an error worth alarming anyone about. + */ +const NoStatistics: FunctionComponent<{ month: string }> = ({ month }) => ( + + No statistics were archived for {month}. Statistics are archived on the first of the following month, so months + before this registry started collecting them have none. + +); + +const StatisticsContent: FunctionComponent<{ statistics: AdminStatistics }> = ({ statistics }) => { + const cards: ReactNode = ( + + + + + + + + + ); + + return ( + <> + {cards} + + `${e.rating}★`)} + values={(statistics.extensionsByRating ?? []).map(e => e.extensions)} + seriesLabel='Extensions' + /> + String(e.extensionsPublished))} + values={(statistics.publishersByExtensionsPublished ?? []).map(e => e.publishers)} + seriesLabel='Publishers' + /> + ({ + label: e.extensionIdentifier, + value: e.downloads, + href: extensionHref(e.extensionIdentifier) + }))} + /> + ({ + label: e.userLoginName, + value: e.publishedExtensionVersions + }))} + /> + ({ + label: e.namespace, + value: e.extensions, + href: `/namespace/${e.namespace}` + }))} + /> + ({ + label: e.namespace, + value: e.extensionVersions, + href: `/namespace/${e.namespace}` + }))} + /> + + + ); +}; + +/** `namespace.name` is what the server reports; the extension page wants them as path segments. */ +function extensionHref(identifier: string): string | undefined { + const separator = identifier.indexOf('.'); + if (separator <= 0) { + return undefined; + } + return `/extension/${identifier.substring(0, separator)}/${identifier.substring(separator + 1)}`; +} diff --git a/webui/src/pages/admin-dashboard/statistics/use-admin-statistics.ts b/webui/src/pages/admin-dashboard/statistics/use-admin-statistics.ts new file mode 100644 index 000000000..0aa186888 --- /dev/null +++ b/webui/src/pages/admin-dashboard/statistics/use-admin-statistics.ts @@ -0,0 +1,38 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { useContext } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { MainContext } from '../../../context'; +import { controllerFromSignal } from '../../../query-client'; + +export const adminStatisticsQueryKey = (year: number, month: number) => ['admin', 'statistics', year, month] as const; + +/** + * Loads the registry statistics for one month. + * + * A month that ended without the archival job running has no data and never will, so the server + * answers 404 - the page presents that as "not archived" rather than as a failure. The month in + * progress is always available, computed on request. + */ +export const useAdminStatistics = (year: number, month: number) => { + const { service } = useContext(MainContext); + return useQuery({ + queryKey: adminStatisticsQueryKey(year, month), + queryFn: ({ signal }) => service.admin.getAdminStatistics(controllerFromSignal(signal), year, month), + // Computing the current month runs a handful of aggregate queries, so don't re-run it on + // every remount while an admin clicks around. + staleTime: 60_000, + retry: false + }); +}; diff --git a/webui/test/unit/pages/admin-dashboard/statistics.spec.tsx b/webui/test/unit/pages/admin-dashboard/statistics.spec.tsx new file mode 100644 index 000000000..c10696a6a --- /dev/null +++ b/webui/test/unit/pages/admin-dashboard/statistics.spec.tsx @@ -0,0 +1,143 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { describe, expect, it, vi } from 'vitest'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DateTime } from 'luxon'; +import { StatisticsAdmin } from '../../../../src/pages/admin-dashboard/statistics/statistics'; +import { ExtensionRegistryService } from '../../../../src/extension-registry-service'; +import { AdminStatistics } from '../../../../src/extension-registry-types'; +import { renderWithProviders } from '../../support/test-providers'; + +const statistics = (overrides: Partial = {}): AdminStatistics => ({ + year: 2026, + month: 8, + extensions: 1234, + downloads: 4567, + downloadsTotal: 89012, + publishers: 345, + averageReviewsPerExtension: 2.5, + namespaceOwners: 67, + extensionsByRating: [ + { rating: 4, extensions: 150 }, + { rating: 5, extensions: 250 } + ], + publishersByExtensionsPublished: [{ extensionsPublished: 1, publishers: 500 }], + topMostActivePublishingUsers: [{ userLoginName: 'busy-bot', publishedExtensionVersions: 400 }], + topNamespaceExtensions: [{ namespace: 'redhat', extensions: 45 }], + topNamespaceExtensionVersions: [{ namespace: 'redhat', extensionVersions: 654 }], + topMostDownloadedExtensions: [{ extensionIdentifier: 'redhat.java', downloads: 40086502 }], + ...overrides +}); + +// not named render*, so the testing-library naming rule doesn't treat the stub it returns as a +// render result +const mountPage = ( + getAdminStatistics: ReturnType = vi.fn().mockResolvedValue(statistics()) +) => { + const admin = { + getAdminStatistics, + getAdminStatisticsCsvUrl: (year: number, month: number) => `/admin/statistics/csv?year=${year}&month=${month}` + }; + renderWithProviders(, { + mainContext: { service: { admin } as unknown as ExtensionRegistryService } + }); + return admin; +}; + +describe('StatisticsAdmin', () => { + it('shows the headline figures for the month', async () => { + mountPage(); + + expect(await screen.findByText('1,234')).toBeInTheDocument(); + expect(screen.getByText('4,567')).toBeInTheDocument(); + expect(screen.getByText('89,012')).toBeInTheDocument(); + expect(screen.getByText('2.50')).toBeInTheDocument(); + }); + + // Opens on the month in progress, which is the only month always available - a fresh registry + // has nothing archived at all. + it('opens on the current month and asks for it', async () => { + const now = DateTime.utc(); + const getAdminStatistics = vi.fn().mockResolvedValue(statistics()); + + mountPage(getAdminStatistics); + + await waitFor(() => expect(getAdminStatistics).toHaveBeenCalled()); + const [, year, month] = getAdminStatistics.mock.calls[0]; + expect(year).toBe(now.year); + expect(month).toBe(now.month); + }); + + it('says the current month is still being calculated', async () => { + mountPage(); + + expect(await screen.findByText(/still in progress and is calculated on request/)).toBeInTheDocument(); + }); + + it('steps back a month and refetches', async () => { + const getAdminStatistics = vi.fn().mockResolvedValue(statistics()); + mountPage(getAdminStatistics); + await waitFor(() => expect(getAdminStatistics).toHaveBeenCalled()); + + await userEvent.click(screen.getByLabelText('Previous month')); + + const lastMonth = DateTime.utc().minus({ months: 1 }); + await waitFor(() => { + expect(getAdminStatistics).toHaveBeenCalledWith(expect.anything(), lastMonth.year, lastMonth.month); + }); + }); + + // There is nothing past the month in progress, and the server rejects it as future. + it('does not offer a month beyond the current one', async () => { + mountPage(); + + await waitFor(() => expect(screen.getByLabelText('Next month')).toBeDisabled()); + }); + + // A month that ended without the archival job running has no data and never will, so this is a + // normal state rather than a failure. + it('explains an unarchived month instead of reporting an error', async () => { + mountPage(vi.fn().mockRejectedValue(new Error('Not Found'))); + + expect(await screen.findByText(/No statistics were archived for/)).toBeInTheDocument(); + }); + + it('offers the CSV export for the shown month', async () => { + mountPage(); + + const now = DateTime.utc(); + const link = await screen.findByRole('link', { name: /Download CSV/ }); + expect(link).toHaveAttribute('href', `/admin/statistics/csv?year=${now.year}&month=${now.month}`); + }); + + it('links the breakdowns to the extensions and namespaces they name', async () => { + mountPage(); + + expect(await screen.findByRole('link', { name: 'redhat.java' })).toHaveAttribute( + 'href', + '/extension/redhat/java' + ); + expect(screen.getAllByRole('link', { name: 'redhat' })[0]).toHaveAttribute('href', '/namespace/redhat'); + }); + + it('names the most active publishing users', async () => { + mountPage(); + + // Scoped to the row: the same figure also appears among the chart's rendered values. + const row = (await screen.findByText('busy-bot')).closest('tr'); + expect(row).not.toBeNull(); + expect(within(row as HTMLElement).getByText('400')).toBeInTheDocument(); + }); +}); From 8a10973cac2233506e75b510a75801f5e1044f97 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Fri, 4 Sep 2026 15:17:42 +0200 Subject: [PATCH 2/4] fix: satisfy the format and lint hooks the analyse job runs Two failures in the prek run, both introduced here. The three static imports added to AdminAPITest went on the end of the block rather than in sorted position, which ImportSort (and the matching Spotless step) rewrites. The statistics spec tripped prettier on a wrapped signature, and testing-library/no-node-access on `.closest('tr')` - the only closest() call in the webui. Scoping by the row's accessible name gets the same row without walking up from the text, so the rule has nothing to object to and the assertion still cannot match the figure the chart renders. Left alone: the record-brace violations Spotless reports in adapter/ExtensionQuery* and json/TargetPlatform*Json. Those predate this branch, are not among its files, and the analyse job is green on main despite them - the Gradle and jbang formatter paths disagree there, which is its own thing to sort out. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/org/eclipse/openvsx/admin/AdminAPITest.java | 6 +++--- .../unit/pages/admin-dashboard/statistics.spec.tsx | 12 +++++------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java index 4256eb931..5688d0011 100644 --- a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java @@ -112,10 +112,13 @@ import org.eclipse.openvsx.util.VersionService; import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; @@ -123,9 +126,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -import static org.hamcrest.Matchers.containsString; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.Mockito.never; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; diff --git a/webui/test/unit/pages/admin-dashboard/statistics.spec.tsx b/webui/test/unit/pages/admin-dashboard/statistics.spec.tsx index c10696a6a..5d603da02 100644 --- a/webui/test/unit/pages/admin-dashboard/statistics.spec.tsx +++ b/webui/test/unit/pages/admin-dashboard/statistics.spec.tsx @@ -43,9 +43,7 @@ const statistics = (overrides: Partial = {}): AdminStatistics = // not named render*, so the testing-library naming rule doesn't treat the stub it returns as a // render result -const mountPage = ( - getAdminStatistics: ReturnType = vi.fn().mockResolvedValue(statistics()) -) => { +const mountPage = (getAdminStatistics: ReturnType = vi.fn().mockResolvedValue(statistics())) => { const admin = { getAdminStatistics, getAdminStatisticsCsvUrl: (year: number, month: number) => `/admin/statistics/csv?year=${year}&month=${month}` @@ -135,9 +133,9 @@ describe('StatisticsAdmin', () => { it('names the most active publishing users', async () => { mountPage(); - // Scoped to the row: the same figure also appears among the chart's rendered values. - const row = (await screen.findByText('busy-bot')).closest('tr'); - expect(row).not.toBeNull(); - expect(within(row as HTMLElement).getByText('400')).toBeInTheDocument(); + // Scoped to the row: the same figure also appears among the chart's rendered values. A row's + // accessible name comes from its cells, so this finds it without walking up from the text. + const row = await screen.findByRole('row', { name: /busy-bot/ }); + expect(within(row).getByText('400')).toBeInTheDocument(); }); }); From d9cd4043034ec392d069d77dd8386ea5d3ee5b6a Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Fri, 4 Sep 2026 15:39:22 +0200 Subject: [PATCH 3/4] fix: tell a failed statistics request apart from an unarchived month Three review points from Copilot, all of them fair. The page rendered "No statistics were archived for " for any query error, so a 500, a 403 or a dropped connection all told an admin the data does not exist - when in truth it had never been fetched. The request layer rejects with an object carrying the HTTP status, so only 404 is now read as "not archived" and anything else says the request failed, with whatever detail came back. The empty state is also gated on having no data, since react-query keeps the last successful payload when a refetch fails, and the alert used to render alongside it. The spec rejected with `new Error('Not Found')`, which is not a shape the request layer produces; both cases now reject with the status they would really carry, and the 500 path has a test of its own that fails against the previous behaviour. Number formatting no longer pins en-US. Everything else in the web UI formats figures in the viewer's locale, the sibling search-index page included, so an admin's own grouping and decimal separators apply here too. Also switches every LocalDateTime.now() in AdminAPITest to TimeUtil.getCurrentUTC(), which is what AdminService uses to decide whether a month is current or future. Six of the eighteen are new here and twelve predate the branch, but Copilot's comment anchors on both, and a file half-converted between the two would be worse than either: at a month boundary a non-UTC developer's clock and the server's would disagree about which month is current. Co-Authored-By: Claude Opus 5 (1M context) --- .../eclipse/openvsx/admin/AdminAPITest.java | 37 ++++++++++--------- .../admin-dashboard/statistics/statistics.tsx | 31 ++++++++++++++-- .../pages/admin-dashboard/statistics.spec.tsx | 14 ++++++- 3 files changed, 59 insertions(+), 23 deletions(-) diff --git a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java index 5688d0011..7bb953cd4 100644 --- a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java @@ -108,6 +108,7 @@ import org.eclipse.openvsx.util.LogService; import org.eclipse.openvsx.util.TargetPlatform; import org.eclipse.openvsx.util.TargetPlatformVersion; +import org.eclipse.openvsx.util.TimeUtil; import org.eclipse.openvsx.util.UUIDService; import org.eclipse.openvsx.util.VersionService; @@ -1513,7 +1514,7 @@ void testReportNegativeYearJson() throws Exception { @Test void testReportFutureYearCsv() throws Exception { var token = mockAdminToken(); - var future = LocalDateTime.now().plusYears(1); + var future = TimeUtil.getCurrentUTC().plusYears(1); mockMvc.perform( get("/admin/report?token={token}&year={year}&month=3", token.getValue(), future.getYear()) .header(HttpHeaders.ACCEPT, "text/csv")) @@ -1524,7 +1525,7 @@ void testReportFutureYearCsv() throws Exception { @Test void testReportFutureYearJson() throws Exception { var token = mockAdminToken(); - var future = LocalDateTime.now().plusYears(1); + var future = TimeUtil.getCurrentUTC().plusYears(1); mockMvc.perform( get("/admin/report?token={token}&year={year}&month=3", token.getValue(), future.getYear()) .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)) @@ -1535,7 +1536,7 @@ void testReportFutureYearJson() throws Exception { @Test void testReportMonthLessThanOneCsv() throws Exception { var token = mockAdminToken(); - var now = LocalDateTime.now(); + var now = TimeUtil.getCurrentUTC(); mockMvc.perform( get("/admin/report?token={token}&year={year}&month=0", token.getValue(), now.getYear()) .header(HttpHeaders.ACCEPT, "text/csv")) @@ -1546,7 +1547,7 @@ void testReportMonthLessThanOneCsv() throws Exception { @Test void testReportMonthLessThanOneJson() throws Exception { var token = mockAdminToken(); - var now = LocalDateTime.now(); + var now = TimeUtil.getCurrentUTC(); mockMvc.perform( get("/admin/report?token={token}&year={year}&month=0", token.getValue(), now.getYear()) .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)) @@ -1557,7 +1558,7 @@ void testReportMonthLessThanOneJson() throws Exception { @Test void testReportMonthGreaterThanTwelveCsv() throws Exception { var token = mockAdminToken(); - var now = LocalDateTime.now(); + var now = TimeUtil.getCurrentUTC(); mockMvc.perform( get("/admin/report?token={token}&year={year}&month=13", token.getValue(), now.getYear()) .header(HttpHeaders.ACCEPT, "text/csv")) @@ -1568,7 +1569,7 @@ void testReportMonthGreaterThanTwelveCsv() throws Exception { @Test void testReportMonthGreaterThanTwelveJson() throws Exception { var token = mockAdminToken(); - var now = LocalDateTime.now(); + var now = TimeUtil.getCurrentUTC(); mockMvc.perform( get("/admin/report?token={token}&year={year}&month=13", token.getValue(), now.getYear()) .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)) @@ -1579,7 +1580,7 @@ void testReportMonthGreaterThanTwelveJson() throws Exception { @Test void testReportFutureMonthCsv() throws Exception { var token = mockAdminToken(); - var future = LocalDateTime.now().plusMonths(1); + var future = TimeUtil.getCurrentUTC().plusMonths(1); mockMvc.perform( get( "/admin/report?token={token}&year={year}&month={month}", @@ -1594,7 +1595,7 @@ void testReportFutureMonthCsv() throws Exception { @Test void testReportFutureMonthJson() throws Exception { var token = mockAdminToken(); - var future = LocalDateTime.now().plusMonths(1); + var future = TimeUtil.getCurrentUTC().plusMonths(1); mockMvc.perform( get( "/admin/report?token={token}&year={year}&month={month}", @@ -1609,7 +1610,7 @@ void testReportFutureMonthJson() throws Exception { @Test void testArchivedReportCsv() throws Exception { var token = mockAdminToken(); - var past = LocalDateTime.now().minusMonths(1); + var past = TimeUtil.getCurrentUTC().minusMonths(1); var year = past.getYear(); var month = past.getMonthValue(); var extensions = 1234; @@ -1692,7 +1693,7 @@ void testArchivedReportCsv() throws Exception { @Test void testArchivedReportJson() throws Exception { var token = mockAdminToken(); - var past = LocalDateTime.now().minusMonths(1); + var past = TimeUtil.getCurrentUTC().minusMonths(1); var year = past.getYear(); var month = past.getMonthValue(); var extensions = 1234; @@ -1822,7 +1823,7 @@ void testArchivedReportJson() throws Exception { @Test void testCurrentMonthAdminReportCsv() throws Exception { var token = mockAdminToken(); - var now = LocalDateTime.now(); + var now = TimeUtil.getCurrentUTC(); var year = now.getYear(); var month = now.getMonthValue(); when(adminStatisticsService.computeAdminStatistics(year, month)) @@ -1838,7 +1839,7 @@ void testCurrentMonthAdminReportCsv() throws Exception { @Test void testCurrentMonthAdminReportJson() throws Exception { var token = mockAdminToken(); - var now = LocalDateTime.now(); + var now = TimeUtil.getCurrentUTC(); var year = now.getYear(); var month = now.getMonthValue(); when(adminStatisticsService.computeAdminStatistics(year, month)) @@ -1860,7 +1861,7 @@ void testCurrentMonthAdminReportJson() throws Exception { @Test void testPastMonthWithoutArchivedReportIsNotFound() throws Exception { var token = mockAdminToken(); - var past = LocalDateTime.now().minusMonths(2); + var past = TimeUtil.getCurrentUTC().minusMonths(2); when(repositories.findAdminStatisticsByYearAndMonth(past.getYear(), past.getMonthValue())).thenReturn(null); mockMvc.perform( @@ -1880,7 +1881,7 @@ void testPastMonthWithoutArchivedReportIsNotFound() throws Exception { @Test void testStatisticsForAnArchivedMonth() throws Exception { mockAdminUser(); - var past = LocalDateTime.now().minusMonths(1); + var past = TimeUtil.getCurrentUTC().minusMonths(1); var year = past.getYear(); var month = past.getMonthValue(); when(repositories.findAdminStatisticsByYearAndMonth(year, month)) @@ -1898,7 +1899,7 @@ void testStatisticsForAnArchivedMonth() throws Exception { @Test void testStatisticsComputesTheCurrentMonth() throws Exception { mockAdminUser(); - var now = LocalDateTime.now(); + var now = TimeUtil.getCurrentUTC(); var year = now.getYear(); var month = now.getMonthValue(); when(adminStatisticsService.computeAdminStatistics(year, month)) @@ -1916,7 +1917,7 @@ void testStatisticsComputesTheCurrentMonth() throws Exception { @Test void testStatisticsNotAdmin() throws Exception { mockNormalUser(); - var past = LocalDateTime.now().minusMonths(1); + var past = TimeUtil.getCurrentUTC().minusMonths(1); mockMvc.perform( get("/admin/statistics?year={year}&month={month}", past.getYear(), past.getMonthValue()) .with(user("test_user")) @@ -1929,7 +1930,7 @@ void testStatisticsNotAdmin() throws Exception { @Test void testStatisticsCsvIsAnAttachment() throws Exception { mockAdminUser(); - var past = LocalDateTime.now().minusMonths(1); + var past = TimeUtil.getCurrentUTC().minusMonths(1); var year = past.getYear(); var month = past.getMonthValue(); when(repositories.findAdminStatisticsByYearAndMonth(year, month)) @@ -1950,7 +1951,7 @@ void testStatisticsCsvIsAnAttachment() throws Exception { @Test void testStatisticsCsvNotAdmin() throws Exception { mockNormalUser(); - var past = LocalDateTime.now().minusMonths(1); + var past = TimeUtil.getCurrentUTC().minusMonths(1); mockMvc.perform( get("/admin/statistics/csv?year={year}&month={month}", past.getYear(), past.getMonthValue()) .with(user("test_user")) diff --git a/webui/src/pages/admin-dashboard/statistics/statistics.tsx b/webui/src/pages/admin-dashboard/statistics/statistics.tsx index 618267cd4..e85bfdc0a 100644 --- a/webui/src/pages/admin-dashboard/statistics/statistics.tsx +++ b/webui/src/pages/admin-dashboard/statistics/statistics.tsx @@ -37,8 +37,10 @@ import { MainContext } from '../../../context'; import type { AdminStatistics } from '../../../extension-registry-types'; import { useAdminStatistics } from './use-admin-statistics'; -const numberFormat = Intl.NumberFormat('en-US'); -const compactFormat = Intl.NumberFormat('en-US', { notation: 'compact' }); +// No explicit locale: every other figure in the web UI is formatted with the viewer's own, from the +// sibling search-index page to the search result count. +const numberFormat = Intl.NumberFormat(); +const compactFormat = Intl.NumberFormat(undefined, { notation: 'compact' }); /** A headline figure, laid out so a row of them reads as one band. */ const StatCard: FunctionComponent<{ label: string; value: string; hint?: string }> = ({ label, value, hint }) => ( @@ -188,12 +190,35 @@ export const StatisticsAdmin: FunctionComponent = () => { {isFetching && !data ? : null} - {error && !isFetching ? : null} + {error && !isFetching && !data ? ( + isNotFoundError(error) ? ( + + ) : ( + + ) + ) : null} {data ? : null} ); }; +/** + * The request layer rejects with an error object carrying the HTTP status (see `server-request.ts`), + * so a month that was never archived is distinguishable from a request that failed. Only the former + * is normal. + */ +const isNotFoundError = (error: unknown): boolean => (error as { status?: number })?.status === 404; + +/** A request that failed, as opposed to a month that has nothing to show. */ +const StatisticsError: FunctionComponent<{ month: string; error: unknown }> = ({ month, error }) => { + const detail = (error as { message?: string })?.message; + return ( + + The statistics for {month} could not be loaded{detail ? `: ${detail}` : '.'} + + ); +}; + /** * A month with no data is the normal state for any month that ended before the registry was * deployed, or one where the archival job did not run - not an error worth alarming anyone about. diff --git a/webui/test/unit/pages/admin-dashboard/statistics.spec.tsx b/webui/test/unit/pages/admin-dashboard/statistics.spec.tsx index 5d603da02..0c2fb7294 100644 --- a/webui/test/unit/pages/admin-dashboard/statistics.spec.tsx +++ b/webui/test/unit/pages/admin-dashboard/statistics.spec.tsx @@ -105,13 +105,23 @@ describe('StatisticsAdmin', () => { }); // A month that ended without the archival job running has no data and never will, so this is a - // normal state rather than a failure. + // normal state rather than a failure. The rejection carries the status the request layer sets, + // since that is what tells this case apart from the one below. it('explains an unarchived month instead of reporting an error', async () => { - mountPage(vi.fn().mockRejectedValue(new Error('Not Found'))); + mountPage(vi.fn().mockRejectedValue({ status: 404, message: 'Not Found' })); expect(await screen.findByText(/No statistics were archived for/)).toBeInTheDocument(); }); + // Reporting a failed request as "not archived" would tell an admin the data does not exist when + // it was never asked for successfully. + it('reports a failed request as an error rather than as an unarchived month', async () => { + mountPage(vi.fn().mockRejectedValue({ status: 500, message: 'Internal Server Error' })); + + expect(await screen.findByText(/could not be loaded/)).toBeInTheDocument(); + expect(screen.queryByText(/No statistics were archived for/)).not.toBeInTheDocument(); + }); + it('offers the CSV export for the shown month', async () => { mountPage(); From e6db858104cf3fd84b4833f955d31e46d774ad19 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Fri, 4 Sep 2026 15:44:13 +0200 Subject: [PATCH 4/4] fix: put Statistics at the end of the admin dashboard sidebar It sat above Namespaces, ahead of the pages an admin actually works in. At the end it joins Logs, Data Consistency and Search Index - the pages you go to to look at the registry rather than to change it. Co-Authored-By: Claude Opus 5 (1M context) --- webui/src/pages/admin-dashboard/admin-dashboard.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/webui/src/pages/admin-dashboard/admin-dashboard.tsx b/webui/src/pages/admin-dashboard/admin-dashboard.tsx index f261fa8f9..604b51c62 100644 --- a/webui/src/pages/admin-dashboard/admin-dashboard.tsx +++ b/webui/src/pages/admin-dashboard/admin-dashboard.tsx @@ -51,12 +51,6 @@ const SearchIndexAdmin = lazy(() => import('./search-index/search-index').then(m const StatisticsAdmin = lazy(() => import('./statistics/statistics').then(m => ({ default: m.StatisticsAdmin }))); const navConfig: NavEntry[] = [ - { - path: AdminDashboardRoutes.STATISTICS, - name: 'Statistics', - icon: , - description: 'Registry statistics per month, with a CSV export' - }, { path: AdminDashboardRoutes.NAMESPACE_ADMIN, name: 'Namespaces', @@ -123,6 +117,12 @@ const navConfig: NavEntry[] = [ name: 'Search Index', icon: , description: 'Inspect the search index and rebuild it' + }, + { + path: AdminDashboardRoutes.STATISTICS, + name: 'Statistics', + icon: , + description: 'Registry statistics per month, with a CSV export' } ];