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..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,14 +108,18 @@ 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; 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,6 +127,7 @@ 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.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 +183,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; @@ -1504,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")) @@ -1515,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)) @@ -1526,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")) @@ -1537,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)) @@ -1548,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")) @@ -1559,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)) @@ -1570,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}", @@ -1585,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}", @@ -1600,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; @@ -1683,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; @@ -1807,34 +1817,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 now = TimeUtil.getCurrentUTC(); + 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 now = TimeUtil.getCurrentUTC(); + 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 = TimeUtil.getCurrentUTC().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 = TimeUtil.getCurrentUTC().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 = TimeUtil.getCurrentUTC(); + 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 = TimeUtil.getCurrentUTC().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 = TimeUtil.getCurrentUTC().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 = TimeUtil.getCurrentUTC().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 +2835,8 @@ AdminService adminService( CacheService cache, JobRequestScheduler scheduler, MailService mail, - LogService logs + LogService logs, + AdminStatisticsService statistics ) { return new AdminService( repositories, @@ -2709,7 +2851,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..604b51c62 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,6 +48,7 @@ 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[] = [ { @@ -115,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' } ]; @@ -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..e85bfdc0a --- /dev/null +++ b/webui/src/pages/admin-dashboard/statistics/statistics.tsx @@ -0,0 +1,312 @@ +/****************************************************************************** + * 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'; + +// 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 }) => ( + + + {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 && !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. + */ +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..0c2fb7294 --- /dev/null +++ b/webui/test/unit/pages/admin-dashboard/statistics.spec.tsx @@ -0,0 +1,151 @@ +/****************************************************************************** + * 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. 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({ 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(); + + 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. 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(); + }); +});