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
85 changes: 71 additions & 14 deletions server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,33 +9,55 @@
********************************************************************************/
package org.eclipse.openvsx.admin;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.net.URI;
import java.time.Period;
import java.time.format.DateTimeParseException;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;

import org.apache.commons.lang3.StringUtils;
import org.eclipse.openvsx.LocalRegistryService;
import org.eclipse.openvsx.entities.AdminStatistics;
import org.eclipse.openvsx.entities.NamespaceMembership;
import org.eclipse.openvsx.entities.PersistedLog;
import org.eclipse.openvsx.json.*;
import org.eclipse.openvsx.json.AdminStatisticsJson;
import org.eclipse.openvsx.json.ChangeNamespaceJson;
import org.eclipse.openvsx.json.ExtensionJson;
import org.eclipse.openvsx.json.NamespaceJson;
import org.eclipse.openvsx.json.NamespaceMembershipListJson;
import org.eclipse.openvsx.json.PersistedLogJson;
import org.eclipse.openvsx.json.ResultJson;
import org.eclipse.openvsx.json.StatsJson;
import org.eclipse.openvsx.json.TargetPlatformVersionJson;
import org.eclipse.openvsx.json.UserPublishInfoJson;
import org.eclipse.openvsx.repositories.RepositoryService;
import org.eclipse.openvsx.search.SearchUtilService;
import org.eclipse.openvsx.util.*;
import org.eclipse.openvsx.util.ErrorResultException;
import org.eclipse.openvsx.util.NamingUtil;
import org.eclipse.openvsx.util.NotFoundException;
import org.eclipse.openvsx.util.TimeUtil;
import org.eclipse.openvsx.util.UrlUtil;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.util.Streamable;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

import java.net.URI;
import java.time.Period;
import java.time.format.DateTimeParseException;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;

@RestController
@ApiResponse(
Expand Down Expand Up @@ -165,6 +187,41 @@ public String getLog(@RequestParam(name = "period", required = false) String per
}
}

@GetMapping(
path = "/admin/logs",
produces = MediaType.APPLICATION_JSON_VALUE
)
public ResponseEntity<Page<PersistedLogJson>> getLog(
Pageable pageable,
@RequestParam(name = "period", required = false) String periodString
) {
try {
admins.checkAdminUser();

Page<PersistedLog> logsPage;
if (StringUtils.isEmpty(periodString)) {
logsPage = repositories.findPersistedLogsPaginated(pageable);
} else {
try {
var period = Period.parse(periodString);
var now = TimeUtil.getCurrentUTC();
logsPage = repositories.findPersistedLogsAfterPaginated(now.minus(period), pageable);
} catch (DateTimeParseException _) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid period");
}
}

return ResponseEntity.ok(logsPage.map(log -> {
var timestamp = log.getTimestamp().minusNanos(log.getTimestamp().getNano());
var json = new PersistedLogJson(timestamp.toString(), log.getUser().getLoginName(), log.getMessage());
return json;
}));
} catch (ErrorResultException exc) {
var status = exc.getStatus() != null ? exc.getStatus() : HttpStatus.BAD_REQUEST;
throw new ResponseStatusException(status);
}
}

private String toString(PersistedLog log) {
var timestamp = log.getTimestamp().minusNanos(log.getTimestamp().getNano());
return timestamp + "\t" + log.getUser().getLoginName() + "\t" + log.getMessage();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2026 Eclipse Foundation AISBL
*
* 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
*/

package org.eclipse.openvsx.json;

import com.fasterxml.jackson.annotation.JsonInclude;

@JsonInclude(JsonInclude.Include.NON_NULL)
public record PersistedLogJson(String timestamp, String user, String message) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
import java.time.LocalDateTime;

import org.eclipse.openvsx.entities.PersistedLog;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.repository.Repository;
import org.springframework.data.util.Streamable;

Expand All @@ -21,4 +23,7 @@ public interface PersistedLogRepository extends Repository<PersistedLog, Long> {

Streamable<PersistedLog> findByTimestampAfterOrderByTimestampAsc(LocalDateTime dateTime);

Page<PersistedLog> findAllByOrderByTimestampDesc(Pageable pageable);

Page<PersistedLog> findByTimestampAfterOrderByTimestampDesc(LocalDateTime dateTime, Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,43 @@
********************************************************************************/
package org.eclipse.openvsx.repositories;

import jakarta.transaction.Transactional;
import org.eclipse.openvsx.entities.*;
import static org.eclipse.openvsx.entities.FileResource.DOWNLOAD;
import static org.eclipse.openvsx.entities.FileResource.DOWNLOAD_SIG;

import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import org.eclipse.openvsx.entities.AdminStatistics;
import org.eclipse.openvsx.entities.Customer;
import org.eclipse.openvsx.entities.Extension;
import org.eclipse.openvsx.entities.ExtensionReview;
import org.eclipse.openvsx.entities.ExtensionVersion;
import org.eclipse.openvsx.entities.FileResource;
import org.eclipse.openvsx.entities.MigrationItem;
import org.eclipse.openvsx.entities.Namespace;
import org.eclipse.openvsx.entities.NamespaceMembership;
import org.eclipse.openvsx.entities.PersistedLog;
import org.eclipse.openvsx.entities.PersonalAccessToken;
import org.eclipse.openvsx.entities.SignatureKeyPair;
import org.eclipse.openvsx.entities.Tier;
import org.eclipse.openvsx.entities.TierType;
import org.eclipse.openvsx.entities.UsageStats;
import org.eclipse.openvsx.entities.UserData;
import org.eclipse.openvsx.json.QueryRequest;
import org.eclipse.openvsx.json.TargetPlatformVersionJson;
import org.eclipse.openvsx.json.UsageStatsJson;
import org.eclipse.openvsx.json.VersionTargetPlatformsJson;
import org.eclipse.openvsx.util.ExtensionId;
import org.eclipse.openvsx.util.NamingUtil;
import org.eclipse.openvsx.web.SitemapRow;
import org.springframework.data.domain.*;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.domain.Sort;
import org.springframework.data.util.Streamable;
import org.springframework.stereotype.Component;

Expand Down Expand Up @@ -377,6 +404,14 @@ public Streamable<PersistedLog> findPersistedLogsAfter(LocalDateTime dateTime) {
return persistedLogRepo.findByTimestampAfterOrderByTimestampAsc(dateTime);
}

public Page<PersistedLog> findPersistedLogsPaginated(Pageable pageable) {
return persistedLogRepo.findAllByOrderByTimestampDesc(pageable);
}

public Page<PersistedLog> findPersistedLogsAfterPaginated(LocalDateTime dateTime, Pageable pageable) {
return persistedLogRepo.findByTimestampAfterOrderByTimestampDesc(dateTime, pageable);
}

public List<String> findAllSucceededDownloadCountProcessedItemsByStorageTypeAndNameIn(String storageType, List<String> names) {
return downloadCountRepo.findAllSucceededDownloadCountProcessedItemsByStorageTypeAndNameIn(storageType, names);
}
Expand Down
24 changes: 23 additions & 1 deletion webui/src/extension-registry-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
Extension, UserData, ExtensionCategory, ExtensionReviewList, PersonalAccessToken, SearchResult, NewReview,
SuccessResult, ErrorResult, CsrfTokenJson, isError, Namespace, NamespaceDetails, MembershipRole, SortBy,
SortOrder, UrlString, NamespaceMembershipList, PublisherInfo, SearchEntry, RegistryVersion,
LoginProviders, Tier, TierList, Customer, CustomerList, UsageStatsList,
LoginProviders, Tier, TierList, Customer, CustomerList, UsageStatsList, LogPageableList,
} from './extension-registry-types';
import { createAbsoluteURL, addQuery } from './utils';
import { sendRequest, ErrorResponse } from './server-request';
Expand Down Expand Up @@ -495,6 +495,7 @@ export interface AdminService {
updateCustomer(abortController: AbortController, name: string, customer: Customer): Promise<Readonly<Customer>>;
deleteCustomer(abortController: AbortController, name: string): Promise<Readonly<SuccessResult | ErrorResult>>;
getUsageStats(abortController: AbortController, customerName: string, date: Date): Promise<Readonly<UsageStatsList>>;
getLogs(abortController: AbortController, page?: number, size?: number, period?: string): Promise<Readonly<LogPageableList>>;
}

export type AdminServiceConstructor = new (registry: ExtensionRegistryService) => AdminService;
Expand Down Expand Up @@ -762,6 +763,27 @@ export class AdminServiceImpl implements AdminService {
credentials: true
}, false);
}

async getLogs(
abortController: AbortController,
page: number = 0,
size: number = 20,
period?: string
): Promise<Readonly<LogPageableList>> {
const query: { key: string, value: string | number }[] = [
{ key: 'page', value: page },
{ key: 'size', value: size }
];
if (period) {
query.push({ key: 'period', value: period });
}
const endpoint = addQuery(createAbsoluteURL([this.registry.serverUrl, 'admin', 'logs']), query);
return sendRequest({
abortController,
endpoint,
credentials: true
}, false);
}
}

export interface ExtensionFilter {
Expand Down
27 changes: 27 additions & 0 deletions webui/src/extension-registry-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,3 +308,30 @@ export interface UsageStats {
export interface UsageStatsList {
stats: UsageStats[];
}

export interface Log {
timestamp: string;
user: string;
message: string;
}

export interface LogPageableList {
content: Log[];
pageable: {
pageNumber: number;
pageSize: number;
sort: { sorted: boolean; empty: boolean; unsorted: boolean };
offset: number;
paged: boolean;
unpaged: boolean;
};
totalElements: number;
totalPages: number;
last: boolean;
first: boolean;
size: number;
number: number;
numberOfElements: number;
sort: { sorted: boolean; empty: boolean; unsorted: boolean };
empty: boolean;
}
5 changes: 5 additions & 0 deletions webui/src/pages/admin-dashboard/admin-dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ import PersonIcon from '@mui/icons-material/Person';
import PeopleIcon from '@mui/icons-material/People';
import StarIcon from '@mui/icons-material/Star';
import BarChartIcon from '@mui/icons-material/BarChart';
import HistoryIcon from '@mui/icons-material/History';
import { Tiers } from './tiers/tiers';
import { Customers } from './customers/customers';
import { UsageStatsView } from './usage-stats/usage-stats';
import { Logs } from './logs/logs';
import { LoginComponent } from "../../default/login";
import AccountBoxIcon from "@mui/icons-material/AccountBox";

Expand All @@ -41,6 +43,7 @@ export namespace AdminDashboardRoutes {
export const TIERS = createRoute([ROOT, 'tiers']);
export const CUSTOMERS = createRoute([ROOT, 'customers']);
export const USAGE_STATS = createRoute([ROOT, 'usage']);
export const LOGS = createRoute([ROOT, 'logs']);
}

const Message: FunctionComponent<{message: string}> = ({ message }) => {
Expand Down Expand Up @@ -73,6 +76,7 @@ export const AdminDashboard: FunctionComponent<AdminDashboardProps> = props => {
<NavigationItem onOpenRoute={handleOpenRoute} active={currentPage === AdminDashboardRoutes.TIERS} label='Tiers' icon={<StarIcon />} route={AdminDashboardRoutes.TIERS} />
<NavigationItem onOpenRoute={handleOpenRoute} active={currentPage === AdminDashboardRoutes.CUSTOMERS} label='Customers' icon={<PeopleIcon />} route={AdminDashboardRoutes.CUSTOMERS} />
<NavigationItem onOpenRoute={handleOpenRoute} active={currentPage?.startsWith(AdminDashboardRoutes.USAGE_STATS)} label='Usage Stats' icon={<BarChartIcon />} route={AdminDashboardRoutes.USAGE_STATS} />
<NavigationItem onOpenRoute={handleOpenRoute} active={currentPage === AdminDashboardRoutes.LOGS} label='Logs' icon={<HistoryIcon />} route={AdminDashboardRoutes.LOGS} />
</Sidepanel>
<Box overflow='auto' flex={1}>
<IconButton onClick={toMainPage} sx={{ float: 'right', mt: 1, mr: 1 }}>
Expand All @@ -87,6 +91,7 @@ export const AdminDashboard: FunctionComponent<AdminDashboardProps> = props => {
<Route path='/customers' element={<Customers/>} />
<Route path='/usage' element={<UsageStatsView/>} />
<Route path='/usage/:customer' element={<UsageStatsView/>} />
<Route path='/logs' element={<Logs/>} />
<Route path='*' element={<Welcome/>} />
</Routes>
</Container>
Expand Down
Loading