Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
* <p>
* {@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<AdminStatisticsJson> 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<String> 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
Expand Down
33 changes: 27 additions & 6 deletions server/src/main/java/org/eclipse/openvsx/admin/AdminService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -108,7 +109,8 @@ public AdminService(
CacheService cache,
JobRequestScheduler scheduler,
MailService mail,
LogService logs
LogService logs,
AdminStatisticsService statistics
) {
this.repositories = repositories;
this.extensions = extensions;
Expand All @@ -123,6 +125,7 @@ public AdminService(
this.scheduler = scheduler;
this.mail = mail;
this.logs = logs;
this.statistics = statistics;
}

@EventListener
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AdminStatisticsJobRequest> {

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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* 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
Expand Down
Loading