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 bcfd1a9f2..7fed818af 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/AdminAPI.java @@ -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( @@ -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> getLog( + Pageable pageable, + @RequestParam(name = "period", required = false) String periodString + ) { + try { + admins.checkAdminUser(); + + Page 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(); diff --git a/server/src/main/java/org/eclipse/openvsx/json/PersistedLogJson.java b/server/src/main/java/org/eclipse/openvsx/json/PersistedLogJson.java new file mode 100644 index 000000000..16b1e2a14 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/PersistedLogJson.java @@ -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) { +} diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/PersistedLogRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/PersistedLogRepository.java index dcc5ad30b..27c54d65a 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/PersistedLogRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/PersistedLogRepository.java @@ -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; @@ -21,4 +23,7 @@ public interface PersistedLogRepository extends Repository { Streamable findByTimestampAfterOrderByTimestampAsc(LocalDateTime dateTime); + Page findAllByOrderByTimestampDesc(Pageable pageable); + + Page findByTimestampAfterOrderByTimestampDesc(LocalDateTime dateTime, Pageable pageable); } \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java index 5a11887a7..27d91bdbe 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -9,8 +9,31 @@ ********************************************************************************/ 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; @@ -18,7 +41,11 @@ 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; @@ -377,6 +404,14 @@ public Streamable findPersistedLogsAfter(LocalDateTime dateTime) { return persistedLogRepo.findByTimestampAfterOrderByTimestampAsc(dateTime); } + public Page findPersistedLogsPaginated(Pageable pageable) { + return persistedLogRepo.findAllByOrderByTimestampDesc(pageable); + } + + public Page findPersistedLogsAfterPaginated(LocalDateTime dateTime, Pageable pageable) { + return persistedLogRepo.findByTimestampAfterOrderByTimestampDesc(dateTime, pageable); + } + public List findAllSucceededDownloadCountProcessedItemsByStorageTypeAndNameIn(String storageType, List names) { return downloadCountRepo.findAllSucceededDownloadCountProcessedItemsByStorageTypeAndNameIn(storageType, names); } diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index e228e5fe0..e59e942c1 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -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'; @@ -495,6 +495,7 @@ export interface AdminService { updateCustomer(abortController: AbortController, name: string, customer: Customer): Promise>; deleteCustomer(abortController: AbortController, name: string): Promise>; getUsageStats(abortController: AbortController, customerName: string, date: Date): Promise>; + getLogs(abortController: AbortController, page?: number, size?: number, period?: string): Promise>; } export type AdminServiceConstructor = new (registry: ExtensionRegistryService) => AdminService; @@ -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> { + 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 { diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index fa22a3344..9566e4037 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -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; +} diff --git a/webui/src/pages/admin-dashboard/admin-dashboard.tsx b/webui/src/pages/admin-dashboard/admin-dashboard.tsx index 427101cfa..80d62e0dc 100644 --- a/webui/src/pages/admin-dashboard/admin-dashboard.tsx +++ b/webui/src/pages/admin-dashboard/admin-dashboard.tsx @@ -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"; @@ -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 }) => { @@ -73,6 +76,7 @@ export const AdminDashboard: FunctionComponent = props => { } route={AdminDashboardRoutes.TIERS} /> } route={AdminDashboardRoutes.CUSTOMERS} /> } route={AdminDashboardRoutes.USAGE_STATS} /> + } route={AdminDashboardRoutes.LOGS} /> @@ -87,6 +91,7 @@ export const AdminDashboard: FunctionComponent = props => { } /> } /> } /> + } /> } /> diff --git a/webui/src/pages/admin-dashboard/logs/logs.tsx b/webui/src/pages/admin-dashboard/logs/logs.tsx new file mode 100644 index 000000000..358db4cff --- /dev/null +++ b/webui/src/pages/admin-dashboard/logs/logs.tsx @@ -0,0 +1,196 @@ +/** + * 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 + */ + +import React, { FC, useState, useEffect, useMemo } from "react"; +import { + Box, + Paper, + Typography, + Alert, + FormControl, + InputLabel, + Select, + MenuItem, + SelectChangeEvent, + Chip, +} from "@mui/material"; +import { DataGrid, GridColDef, GridPaginationModel, GridRenderCellParams } from "@mui/x-data-grid"; +import { MainContext } from "../../../context"; +import type { Log } from "../../../extension-registry-types"; +import { handleError } from "../../../utils"; +import { createMultiSelectFilterOperators } from "../components"; + +type PeriodFilter = '' | 'P1D' | 'P7D' | 'P30D' | 'P90D' | 'P1Y'; + +const periodOptions: { value: PeriodFilter; label: string }[] = [ + { value: '', label: 'All Time' }, + { value: 'P1D', label: 'Last 24 Hours' }, + { value: 'P7D', label: 'Last 7 Days' }, + { value: 'P30D', label: 'Last 30 Days' }, + { value: 'P90D', label: 'Last 90 Days' }, + { value: 'P1Y', label: 'Last Year' }, +]; + +export const Logs: FC = () => { + const { service } = React.useContext(MainContext); + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: 20, + }); + const [totalElements, setTotalElements] = useState(0); + const [periodFilter, setPeriodFilter] = useState(''); + const logsWithId = useMemo(() => + logs.map((log, index) => ({ ...log, id: index })), + [logs] + ); + + useEffect(() => { + const abortController = new AbortController(); + + const loadLogs = async () => { + try { + setLoading(true); + setError(null); + const data = await service.admin.getLogs( + abortController, + paginationModel.page, + paginationModel.pageSize, + periodFilter || undefined + ); + setLogs(data.content); + setTotalElements(data.totalElements); + } catch (err: any) { + if (!abortController.signal.aborted) { + setError(handleError(err)); + } + } finally { + if (!abortController.signal.aborted) { + setLoading(false); + } + } + }; + + loadLogs(); + return () => abortController.abort(); + }, [service, paginationModel.page, paginationModel.pageSize, periodFilter]); + + const handlePaginationModelChange = (newModel: GridPaginationModel) => { + setPaginationModel(newModel); + }; + + const handlePeriodChange = (event: SelectChangeEvent) => { + setPeriodFilter(event.target.value as PeriodFilter); + // Reset to first page when filter changes + setPaginationModel(prev => ({ ...prev, page: 0 })); + }; + + // Extract unique values for filter dropdowns + const userOptions = useMemo(() => + [...new Set(logs.map(l => l.user).filter(Boolean))], + [logs] + ); + + const columns: GridColDef[] = [ + { + field: 'timestamp', + headerName: 'Timestamp', + width: 200, + sortable: false, + renderCell: (params: GridRenderCellParams) => { + if (!params.value) return null; + const date = new Date(params.value as string); + return ( + + ); + } + }, + { + field: 'user', + headerName: 'User', + flex: 1, + minWidth: 150, + sortable: false, + filterOperators: createMultiSelectFilterOperators(userOptions) + }, + { + field: 'message', + headerName: 'Message', + flex: 2, + minWidth: 300, + sortable: false, + }, + ]; + + return ( + + + + Admin Logs + + + Time Period + + + + + {error && ( + setError(null)}> + {error} + + )} + + {!loading && !error && logs.length === 0 && ( + + + No logs found for the selected time period. + + + )} + + {!error && logs.length > 0 && ( + + + + )} + + ); +}; diff --git a/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx b/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx index 36c80fbf8..299546451 100644 --- a/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx +++ b/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx @@ -11,8 +11,8 @@ * SPDX-License-Identifier: EPL-2.0 *****************************************************************************/ -import React, {FC, useEffect, useState} from 'react'; -import type {SelectChangeEvent} from '@mui/material'; +import React, { FC, useEffect, useState } from 'react'; +import type { SelectChangeEvent } from '@mui/material'; import { Alert, Box, @@ -29,8 +29,8 @@ import { Select, TextField } from '@mui/material'; -import {RefillStrategy, type Tier, TierType} from "../../../extension-registry-types"; -import {handleError} from "../../../utils"; +import { RefillStrategy, type Tier, TierType } from "../../../extension-registry-types"; +import { handleError } from "../../../utils"; type DurationUnit = 'seconds' | 'minutes' | 'hours';