feat: add an admin statistics dashboard - #2156
Conversation
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The new dashboard currently treats any fetch failure as “not archived” (masking real errors), and the added server API tests use local time instead of UTC which can make them flaky around month boundaries.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds an admin-facing “Statistics” dashboard and supporting server endpoints to display monthly registry-wide stats (including current-month on-the-fly computation) and provide a CSV export, aligning with the remaining asks from #351 / #235 without breaking the existing token-based /admin/report integrations.
Changes:
- Server: add session-authenticated
/admin/statistics(JSON) and/admin/statistics/csv(CSV attachment) endpoints and allow current-month stats to be computed on demand. - Server: refactor monthly computation into
AdminStatisticsServiceso the archival job and on-demand path share identical logic. - Web UI: add
/admin-dashboard/statisticspage with month navigation, charts/tables, and CSV download; add unit tests.
File summaries
| File | Description |
|---|---|
| webui/test/unit/pages/admin-dashboard/statistics.spec.tsx | Adds unit tests for the new admin statistics page. |
| webui/src/pages/admin-dashboard/statistics/use-admin-statistics.ts | Introduces a React Query hook for fetching monthly admin statistics. |
| webui/src/pages/admin-dashboard/statistics/statistics.tsx | Implements the admin statistics dashboard UI (cards, charts, tables, CSV link). |
| webui/src/pages/admin-dashboard/admin-dashboard.tsx | Registers the new Statistics nav entry and route. |
| webui/src/pages/admin-dashboard/admin-dashboard-routes.ts | Adds the STATISTICS route constant. |
| webui/src/extension-registry-types.ts | Adds the AdminStatistics type mirroring the server JSON payload. |
| webui/src/extension-registry-service.ts | Adds getAdminStatistics + CSV URL helper to the admin service client. |
| server/src/test/java/org/eclipse/openvsx/admin/AdminStatisticsServiceTest.java | Adds focused unit tests for the refactored statistics computation. |
| server/src/test/java/org/eclipse/openvsx/admin/AdminStatisticsJobRequestHandlerTest.java | Updates the job handler test to verify delegation to the service. |
| server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java | Extends API tests for current-month behavior and new /admin/statistics* endpoints. |
| server/src/main/java/org/eclipse/openvsx/admin/AdminStatisticsService.java | Adds computeAdminStatistics and shared computation logic (TOP_LIMIT, downloads delta). |
| server/src/main/java/org/eclipse/openvsx/admin/AdminStatisticsJobRequestHandler.java | Refactors handler to compute via service and persist archived rows. |
| server/src/main/java/org/eclipse/openvsx/admin/AdminService.java | Serves current-month stats on demand; relaxes future-month validation. |
| server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java | Adds session-authenticated JSON + CSV endpoints for the dashboard. |
Review details
Suppressed comments (7)
webui/src/pages/admin-dashboard/statistics/statistics.tsx:192
StatisticsAdmincurrently shows the “No statistics were archived…” empty-state for any query error (including network/5xx/403), and it can also show the empty-state while still rendering previously cached data. It should only treat HTTP 404 as the normal “not archived” case, and handle other errors separately (e.g. show an error alert / bubble up to global error handling).
{isFetching && !data ? <CircularProgress /> : null}
{error && !isFetching ? <NoStatistics month={heading} /> : null}
{data ? <StatisticsContent statistics={data} /> : null}
server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java:1842
- Same UTC-vs-local-time issue here:
LocalDateTime.now()can disagree with the service’sTimeUtil.getCurrentUTC()month logic and make this test flaky at month boundaries.
void testCurrentMonthAdminReportJson() throws Exception {
var token = mockAdminToken();
var now = LocalDateTime.now();
var year = now.getYear();
server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java:1864
- Same UTC-vs-local-time issue:
pastis derived fromLocalDateTime.now()but the API uses UTC for deciding whether a month is current/future. UseTimeUtil.getCurrentUTC()to avoid boundary flakiness.
void testPastMonthWithoutArchivedReportIsNotFound() throws Exception {
var token = mockAdminToken();
var past = LocalDateTime.now().minusMonths(2);
when(repositories.findAdminStatisticsByYearAndMonth(past.getYear(), past.getMonthValue())).thenReturn(null);
server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java:1884
- Same UTC-vs-local-time issue: this archived-month test derives
pastfrom the system zone, but the server-side month logic is UTC-based; useTimeUtil.getCurrentUTC()to avoid flakiness around month boundaries.
void testStatisticsForAnArchivedMonth() throws Exception {
mockAdminUser();
var past = LocalDateTime.now().minusMonths(1);
var year = past.getYear();
server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java:1902
- Same UTC-vs-local-time issue: use
TimeUtil.getCurrentUTC()instead ofLocalDateTime.now()so the test’s “current month” matches the service’s UTC-based logic.
@Test
void testStatisticsComputesTheCurrentMonth() throws Exception {
mockAdminUser();
var now = LocalDateTime.now();
var year = now.getYear();
server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java:1934
- Same UTC-vs-local-time issue for the CSV attachment test: deriving
pastfromLocalDateTime.now()can mismatch the server’s UTC month logic around boundaries; useTimeUtil.getCurrentUTC()here too.
void testStatisticsCsvIsAnAttachment() throws Exception {
mockAdminUser();
var past = LocalDateTime.now().minusMonths(1);
var year = past.getYear();
var month = past.getMonthValue();
server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java:1955
- Same UTC-vs-local-time issue for the “not admin” CSV test: use UTC-derived month/year so the request month classification matches production behavior.
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())
- Files reviewed: 14/14 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Three review points from Copilot, all of them fair.
The page rendered "No statistics were archived for <month>" 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Closes #351. Adds a statistics page to the admin dashboard at
/admin-dashboard/statistics, with a CSV export.Two of that issue's three asks had already been met since it was filed in 2021:
/admin/reportgained a JSON variant, and the webui now ships@mui/x-charts, so the d3js suggestion is moot. That left the auth change, the missing current month, and the page itself.1. New session-authenticated endpoints, rather than changing
/admin/reportThe issue suggested switching
/admin/reportto Spring authentication. I added/admin/statisticsand/admin/statistics/csvalongside it instead, because/admin/reporttakes its token as a request parameter and is consequently listed inSecurityConfig's permitAll block — which is exactly why a logged-in browser session can't use it, and why changing it would break anyone scripting the CSV export today.The new endpoints authenticate with
checkAdminUser(), the way every other endpoint under/admin/does, and need noSecurityConfigchange:/admin/**already requiresROLE_ADMIN, so leaving them out of the permitAll list is all it takes.The CSV sits on its own path rather than behind content negotiation, because the download is a plain link and a browser navigation can't set an
Acceptheader. It setsContent-Disposition, so the file saves asopenvsx-statistics-2026-09.csvinstead of being named after the route.2. The month in progress is now computed on request
#235 specified "when current month data is requested, calculations are done on the fly", and that was never built.
getAdminStatisticsrejected the current month as lying 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, since the archival job only runs on the first of the following month.The computation moves out of
AdminStatisticsJobRequestHandlerintoAdminStatisticsService, so the archival job and the on-the-fly path cannot compute the same month differently. The validation relaxes frommonth >= nowtomonth > now.The on-the-fly result is deliberately not saved: it is a partial month, and the job will archive the complete figure in its own time. A past month with no row stays a 404 — every figure except
downloadsis a snapshot of the registry as it was, so it cannot be reconstructed after the fact, and reporting today's numbers under a past month's heading would be worse than reporting nothing.This does change
/admin/report's behaviour for the current month, from400to200. Permissive rather than breaking, but it is a public endpoint, so flagging it rather than burying it.3. The page
Reads the endpoint a month at a time:
usage-statschart's@mui/x-chartsusage rather than introducing anything new;Months step one at a time, forward navigation stops at the month in progress, and a month with nothing archived is explained rather than surfaced as a failure — for any registry that wasn't running yet, that is the normal state, not an error.
Note the existing Usage Stats page is per-customer rate-limit usage and unrelated to this, despite the similar name.
Testing
Server 1148 tests, webui 285 across 55 files,
tscandeslintclean. New coverage:AdminStatisticsServiceTestfor the computation, fiveAdminAPITestcases for the new endpoints (archived month, computed current month, CSV attachment, and non-admin rejection on both), and nine webui cases for the page.Two test changes are deliberate behaviour changes rather than fixes, and are the places to look first in review:
testCurrentMonthAdminReportCsv/...Jsonasserted the current month returns400 Combination of year and month lies in the future. They now assert it is computed — the Deployment Statistics #235 behaviour above.AdminStatisticsServiceTestwith the logic they cover, leaving the job's own test as a thin delegation check.AdminAPITestalso neededAdminStatisticsServiceregistered as a mock bean, since it constructsAdminServicedirectly and the constructor gained an argument.Not included
There is no "which months have data" endpoint, so stepping back through unarchived months shows the empty-state message one month at a time. That is fine on open-vsx.org, which has years of history, and slightly clunky on a young registry. A month index would fix it and is a small addition — happy to add it here or separately if you would rather the picker only offered months that exist.
🤖 Generated with Claude Code