From 20031d1a4e24bfe8a01e78e08221fb3be927de8c Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Thu, 19 Mar 2026 10:32:33 +0100 Subject: [PATCH 01/18] fix: use UTC dates consistently when displaying the usage data, switch to luxon for timezone support --- webui/package.json | 3 +- .../usage-stats/usage-stats-chart.tsx | 25 +++++++++-------- .../usage-stats/usage-stats-utils.ts | 6 ++-- .../usage-stats/usage-stats.tsx | 5 ++-- webui/yarn.lock | 28 +++++++++++-------- 5 files changed, 40 insertions(+), 27 deletions(-) diff --git a/webui/package.json b/webui/package.json index ff7cbc72f..a9c05db36 100644 --- a/webui/package.json +++ b/webui/package.json @@ -52,10 +52,10 @@ "@mui/x-date-pickers": "^6.20", "clipboard-copy": "^4.0.1", "clsx": "^1.2.1", - "date-fns": "^2.30.0", "dompurify": "^3.0.4", "fetch-retry": "^5.0.6", "lodash": "^4.17.21", + "luxon": "^3.7.2", "markdown-it": "^14.1.0", "markdown-it-anchor": "^9.2.0", "prop-types": "^15.8.1", @@ -84,6 +84,7 @@ "@types/dompurify": "^3.0.2", "@types/express": "^4.17.21", "@types/lodash": "^4.14.195", + "@types/luxon": "^3.7.1", "@types/markdown-it": "^14.1.0", "@types/mocha": "^10.0.0", "@types/node": "^22.0.0", diff --git a/webui/src/pages/admin-dashboard/usage-stats/usage-stats-chart.tsx b/webui/src/pages/admin-dashboard/usage-stats/usage-stats-chart.tsx index 576e0baef..462b1bf14 100644 --- a/webui/src/pages/admin-dashboard/usage-stats/usage-stats-chart.tsx +++ b/webui/src/pages/admin-dashboard/usage-stats/usage-stats-chart.tsx @@ -22,9 +22,8 @@ import { import { BarPlot } from "@mui/x-charts/BarChart"; import { DatePicker } from "@mui/x-date-pickers/DatePicker"; import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; -import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns"; +import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon"; import type { Customer, UsageStats } from "../../../extension-registry-types"; -import { addDays, format, startOfDay } from "date-fns"; import { ChartsReferenceLine, ChartsTooltip, @@ -32,12 +31,13 @@ import { ChartsYAxis, ResponsiveChartContainer } from "@mui/x-charts"; +import { DateTime } from "luxon"; interface UsageStatsChartProps { usageStats: readonly UsageStats[]; customer: Customer | null; - startDate: Date; - onStartDateChange: (date: Date) => void; + startDate: DateTime; + onStartDateChange: (date: DateTime) => void; } export const UsageStatsChart: FC = ({ @@ -46,8 +46,8 @@ export const UsageStatsChart: FC = ({ startDate, onStartDateChange }) => { - const dayStart = startOfDay(startDate).getTime() / 1000; - const dayEnd = startOfDay(addDays(startDate, 1)).getTime() / 1000; + const dayStart = startDate.startOf('day').toMillis() / 1000; + const dayEnd = startDate.endOf('day').toMillis() / 1000; // we have 5min steps const step = 5 * 60; @@ -68,7 +68,9 @@ export const UsageStatsChart: FC = ({ for (const stat of usageStats) { const idx = (stat.windowStart - dayStart) / step; - arr[idx].count = stat.count; + if (idx >= 0 && idx < arr.length) { + arr[idx].count = stat.count; + } } return arr; }, [usageStats] @@ -90,7 +92,7 @@ export const UsageStatsChart: FC = ({ ); return ( - + Filters @@ -100,7 +102,8 @@ export const UsageStatsChart: FC = ({ label='Start Date' value={startDate} onChange={onStartDateChange} - slotProps={{ textField: { size: 'small' } }} + timezone='UTC' + slotProps={{ textField: { size: 'small' }, actionBar: { actions: ['today'] } }} /> @@ -123,9 +126,9 @@ export const UsageStatsChart: FC = ({ xAxis={[ { id: 'date', - data: data.map((value) => new Date(value.windowStart * 1000)), + data: data.map((value) => value.windowStart * 1000), + valueFormatter: (value) => DateTime.fromMillis(value).toLocaleString(DateTime.TIME_24_SIMPLE), scaleType: 'band', - valueFormatter: (value) => format(new Date(value), 'HH:mm'), }, ]} yAxis={[ diff --git a/webui/src/pages/admin-dashboard/usage-stats/usage-stats-utils.ts b/webui/src/pages/admin-dashboard/usage-stats/usage-stats-utils.ts index c36de3302..57146cddb 100644 --- a/webui/src/pages/admin-dashboard/usage-stats/usage-stats-utils.ts +++ b/webui/src/pages/admin-dashboard/usage-stats/usage-stats-utils.ts @@ -11,6 +11,8 @@ * SPDX-License-Identifier: EPL-2.0 *****************************************************************************/ +import { DateTime } from "luxon"; + export const getDefaultStartDate = () => { - return new Date(); -}; \ No newline at end of file + return DateTime.now().setZone("UTC"); +}; diff --git a/webui/src/pages/admin-dashboard/usage-stats/usage-stats.tsx b/webui/src/pages/admin-dashboard/usage-stats/usage-stats.tsx index 4bc0c46d7..96be189f6 100644 --- a/webui/src/pages/admin-dashboard/usage-stats/usage-stats.tsx +++ b/webui/src/pages/admin-dashboard/usage-stats/usage-stats.tsx @@ -22,6 +22,7 @@ import { SearchListContainer } from "../search-list-container"; import { CustomerSearch } from "./usage-stats-search"; import { UsageStatsChart } from "./usage-stats-chart"; import { getDefaultStartDate } from "./usage-stats-utils"; +import { DateTime } from "luxon"; export const UsageStatsView: FC = () => { const { customer } = useParams<{ customer: string }>(); @@ -34,7 +35,7 @@ export const UsageStatsView: FC = () => { const [usageStats, setUsageStats] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [startDate, setStartDate] = useState(getDefaultStartDate); + const [startDate, setStartDate] = useState(getDefaultStartDate); // Load customers for autocomplete useEffect(() => { @@ -79,7 +80,7 @@ export const UsageStatsView: FC = () => { const data = await service.admin.getUsageStats( abortController.current, customer, - startDate + startDate.toJSDate() ); setUsageStats(data.stats); } catch (err) { diff --git a/webui/yarn.lock b/webui/yarn.lock index 902efde0a..fa538051d 100644 --- a/webui/yarn.lock +++ b/webui/yarn.lock @@ -317,7 +317,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.28.4": +"@babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.28.4": version: 7.28.6 resolution: "@babel/runtime@npm:7.28.6" checksum: 10/fbcd439cb74d4a681958eb064c509829e3f46d8a4bfaaf441baa81bb6733d1e680bccc676c813883d7741bcaada1d0d04b15aa320ef280b5734e2192b50decf9 @@ -1904,6 +1904,13 @@ __metadata: languageName: node linkType: hard +"@types/luxon@npm:^3.7.1": + version: 3.7.1 + resolution: "@types/luxon@npm:3.7.1" + checksum: 10/c7bc164c278393ea0be938f986c74b4cddfab9013b1aff4495b016f771ded1d5b7b7b4825b2c7f0b8799edce19c5f531c28ff434ab3dedf994ac2d99a20fd4c4 + languageName: node + linkType: hard + "@types/markdown-it@npm:^14.1.0, @types/markdown-it@npm:^14.1.2": version: 14.1.2 resolution: "@types/markdown-it@npm:14.1.2" @@ -3151,15 +3158,6 @@ __metadata: languageName: node linkType: hard -"date-fns@npm:^2.30.0": - version: 2.30.0 - resolution: "date-fns@npm:2.30.0" - dependencies: - "@babel/runtime": "npm:^7.21.0" - checksum: 10/70b3e8ea7aaaaeaa2cd80bd889622a4bcb5d8028b4de9162cbcda359db06e16ff6e9309e54eead5341e71031818497f19aaf9839c87d1aba1e27bb4796e758a9 - languageName: node - linkType: hard - "debug@npm:2.6.9": version: 2.6.9 resolution: "debug@npm:2.6.9" @@ -5357,6 +5355,13 @@ __metadata: languageName: node linkType: hard +"luxon@npm:^3.7.2": + version: 3.7.2 + resolution: "luxon@npm:3.7.2" + checksum: 10/b24cd205ed306ce7415991687897dcc4027921ae413c9116590bc33a95f93b86ce52cf74ba72b4f5c5ab1c10090517f54ac8edfb127c049e0bf55b90dc2260be + languageName: node + linkType: hard + "make-error@npm:^1.1.1": version: 1.3.6 resolution: "make-error@npm:1.3.6" @@ -5849,6 +5854,7 @@ __metadata: "@types/dompurify": "npm:^3.0.2" "@types/express": "npm:^4.17.21" "@types/lodash": "npm:^4.14.195" + "@types/luxon": "npm:^3.7.1" "@types/markdown-it": "npm:^14.1.0" "@types/mocha": "npm:^10.0.0" "@types/node": "npm:^22.0.0" @@ -5866,7 +5872,6 @@ __metadata: chai: "npm:^4.3.0" clipboard-copy: "npm:^4.0.1" clsx: "npm:^1.2.1" - date-fns: "npm:^2.30.0" dompurify: "npm:^3.0.4" eslint: "npm:^9.39.0" eslint-plugin-react: "npm:^7.37.0" @@ -5874,6 +5879,7 @@ __metadata: express-rate-limit: "npm:^7.4.0" fetch-retry: "npm:^5.0.6" lodash: "npm:^4.17.21" + luxon: "npm:^3.7.2" markdown-it: "npm:^14.1.0" markdown-it-anchor: "npm:^9.2.0" mocha: "npm:^11.7.0" From a8ac53dff32816c1f0b2000d4d882e5226ad351a Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Thu, 19 Mar 2026 13:32:54 +0100 Subject: [PATCH 02/18] update time label to indicate that this is UTC, add top margin to chart --- .../pages/admin-dashboard/usage-stats/usage-stats-chart.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webui/src/pages/admin-dashboard/usage-stats/usage-stats-chart.tsx b/webui/src/pages/admin-dashboard/usage-stats/usage-stats-chart.tsx index 462b1bf14..38d0e468d 100644 --- a/webui/src/pages/admin-dashboard/usage-stats/usage-stats-chart.tsx +++ b/webui/src/pages/admin-dashboard/usage-stats/usage-stats-chart.tsx @@ -136,7 +136,7 @@ export const UsageStatsChart: FC = ({ id: 'requests', scaleType: 'linear', min: 0, - max: Math.max(tierCapacity, maxDataValue) + 10 + max: Math.max(tierCapacity, maxDataValue) + 30 }, ]} > @@ -156,7 +156,7 @@ export const UsageStatsChart: FC = ({ } { From e8b8a6e91d4781187ef94b73f777e32beb984c5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez=20Hidalgo?= <31970428+gnugomez@users.noreply.github.com> Date: Thu, 19 Mar 2026 20:36:44 +0100 Subject: [PATCH 03/18] feat(UI): adding option to add members to customers (#1700) * feat: adding customer details page * feat: adding members to customer view * feat: exposing get customer by name endpoint * feat: using get single customer * refactor: generalising add user dialog * feat: updating styles * feat: debouncing user search calls * feat: breaking customer details page into multiple components * refactor: optimizing useUsageStats hook * chore: removing unnecessary route definition * fix: abort controller not properly recreated * refactor: using autocomplete over popper --- .../eclipse/openvsx/admin/RateLimitAPI.java | 20 ++ webui/src/extension-registry-service.ts | 57 ++- webui/src/extension-registry-types.ts | 1 + .../pages/admin-dashboard/admin-dashboard.tsx | 3 + .../customers/customer-details.tsx | 335 ++++++++++++++++++ .../admin-dashboard/customers/customers.tsx | 33 +- webui/src/pages/admin-dashboard/logs/logs.tsx | 11 +- .../pages/admin-dashboard/publisher-admin.tsx | 38 +- .../usage-stats/usage-stats-chart.tsx | 25 +- .../usage-stats/usage-stats.tsx | 46 +-- .../usage-stats/use-usage-stats.ts | 70 ++++ .../user/add-namespace-member-dialog.tsx | 133 +------ webui/src/pages/user/add-user-dialog.tsx | 141 ++++++++ 13 files changed, 725 insertions(+), 188 deletions(-) create mode 100644 webui/src/pages/admin-dashboard/customers/customer-details.tsx create mode 100644 webui/src/pages/admin-dashboard/usage-stats/use-usage-stats.ts create mode 100644 webui/src/pages/user/add-user-dialog.tsx diff --git a/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java index 319256e47..d2d5ba745 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java @@ -222,6 +222,26 @@ public ResponseEntity getCustomers() { } } + @GetMapping( + path = "/customers/{name}", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity getCustomer(@PathVariable String name) { + try { + admins.checkAdminUser(); + + var customer = repositories.findCustomer(name); + if (customer == null) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.ok(customer.toJson()); + } catch (Exception exc) { + logger.error("failed retrieving customer {}", name, exc); + return ResponseEntity.internalServerError().build(); + } + } + @PostMapping( path = "/customers/create", consumes = MediaType.APPLICATION_JSON_VALUE, diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index db8ff77e5..ad2638e51 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -505,6 +505,7 @@ export interface AdminService { updateTier(abortController: AbortController, name: string, tier: Tier): Promise>; deleteTier(abortController: AbortController, name: string): Promise>; getCustomers(abortController: AbortController): Promise>; + getCustomer(abortController: AbortController, name: string): Promise>; createCustomer(abortController: AbortController, customer: Customer): Promise>; updateCustomer(abortController: AbortController, name: string, customer: Customer): Promise>; deleteCustomer(abortController: AbortController, name: string): Promise>; @@ -884,12 +885,66 @@ export class AdminServiceImpl implements AdminService { }, false); } + // TODO: Remove mock users when backend returns real user data + private static readonly MOCK_USERS: UserData[] = [ + { + loginName: 'rhdevelopers-ci', + fullName: 'Red Hat Developers CI', + avatarUrl: 'https://avatars.githubusercontent.com/u/18214726?v=4', + homepage: 'https://github.com/rhdevelopers-ci', + provider: 'github', + tokensUrl: '', + createTokenUrl: '' + }, + { + loginName: 'midudev', + fullName: 'Miguel Ángel Durán', + avatarUrl: 'https://avatars.githubusercontent.com/u/1561955?v=4', + homepage: 'https://github.com/midudev', + provider: 'github', + tokensUrl: '', + createTokenUrl: '' + }, + { + loginName: 'jakubmisek', + fullName: 'Jakub Míšek', + avatarUrl: 'https://avatars.githubusercontent.com/u/842150?v=4', + homepage: 'https://github.com/jakubmisek', + provider: 'github', + tokensUrl: '', + createTokenUrl: '' + }, + { + loginName: 'test', + fullName: 'Miguel Ángel Durán', + avatarUrl: 'https://avatars.githubusercontent.com/u/1561955?v=4', + homepage: 'https://github.com/midudev', + provider: 'github', + tokensUrl: '', + createTokenUrl: '' + } + ]; + + private injectMockUsers(customer: Customer): Customer { + return { ...customer, users: AdminServiceImpl.MOCK_USERS }; + } + async getCustomers(abortController: AbortController): Promise> { - return sendRequest({ + const data: CustomerList = await sendRequest({ abortController, endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'customers']), credentials: true }, false); + return { customers: data.customers.map(this.injectMockUsers) }; + } + + async getCustomer(abortController: AbortController, name: string): Promise> { + const data: Customer = await sendRequest({ + abortController, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'customers', name]), + credentials: true + }, false); + return this.injectMockUsers(data); } async createCustomer(abortController: AbortController, customer: Customer): Promise> { diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index 6bbdb2ae9..09f3fbe6c 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -452,6 +452,7 @@ export interface Customer { tier?: Tier; state: EnforcementState; cidrBlocks: string[]; + users?: UserData[]; } export interface CustomerList { diff --git a/webui/src/pages/admin-dashboard/admin-dashboard.tsx b/webui/src/pages/admin-dashboard/admin-dashboard.tsx index 421f1002d..d38f9f361 100644 --- a/webui/src/pages/admin-dashboard/admin-dashboard.tsx +++ b/webui/src/pages/admin-dashboard/admin-dashboard.tsx @@ -31,6 +31,7 @@ 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 { CustomerDetails } from './customers/customer-details'; import { UsageStatsView } from './usage-stats/usage-stats'; import { Logs } from './logs/logs'; import { LoginComponent } from "../../default/login"; @@ -111,9 +112,11 @@ export const AdminDashboard: FunctionComponent = props => { } /> } /> } /> + } /> } /> } /> } /> + } /> } /> } /> } /> diff --git a/webui/src/pages/admin-dashboard/customers/customer-details.tsx b/webui/src/pages/admin-dashboard/customers/customer-details.tsx new file mode 100644 index 000000000..ddfc608e0 --- /dev/null +++ b/webui/src/pages/admin-dashboard/customers/customer-details.tsx @@ -0,0 +1,335 @@ +/****************************************************************************** + * 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 { FC, useContext, useState, useEffect, useRef, useCallback } from "react"; +import { + Box, + Typography, + Paper, + type PaperProps, + type SxProps, + type Theme, + Chip, + Stack, + Alert, + Button, + Divider, + Avatar, + IconButton, + List, + ListItem, + ListItemAvatar, + ListItemText, + Grid, + LinearProgress +} from "@mui/material"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import EditIcon from "@mui/icons-material/Edit"; +import PersonAddIcon from "@mui/icons-material/PersonAdd"; +import DeleteIcon from "@mui/icons-material/Delete"; +import { useParams, useNavigate, Link as RouterLink } from "react-router-dom"; +import { MainContext } from "../../../context"; +import type { Customer, UserData, UsageStats } from "../../../extension-registry-types"; +import type { DateTime } from "luxon"; +import { handleError } from "../../../utils"; +import { AdminDashboardRoutes } from "../admin-dashboard"; +import { UsageStatsChart } from "../usage-stats/usage-stats-chart"; +import { useUsageStats } from "../usage-stats/use-usage-stats"; +import { CustomerFormDialog } from "./customer-form-dialog"; +import { AddUserDialog } from "../../user/add-user-dialog"; + +const sectionPaperProps: PaperProps = { elevation: 1, sx: { p: 3, mb: 3 } }; + +const BackToCustomersButton: FC<{ sx?: SxProps }> = ({ sx }) => { + const navigate = useNavigate(); + return ( + + ); +}; + +const CustomerDetailsLoading: FC = () => ( + + + + +); + +const CustomerDetailsError: FC<{ message: string }> = ({ message }) => ( + + + {message} + +); + +const GeneralInformationSection: FC<{ customer: Customer; onEdit: () => void }> = ({ customer, onEdit }) => { + const tier = customer.tier; + return ( + + + General Information + + + + + + Name + {customer.name} + + + State + + + + + {tier ? ( + <> + + Tier + + + + + + Tier Type + {tier.tierType} + + + Capacity + {tier.capacity} requests / {tier.duration}s + + + Refill Strategy + {tier.refillStrategy} + + {tier.description && ( + + Tier Description + {tier.description} + + )} + + ) : ( + + Tier + No tier assigned + + )} + + CIDR Blocks + {customer.cidrBlocks.length > 0 ? ( + + {customer.cidrBlocks.map((cidr) => ( + + ))} + + ) : ( + None configured + )} + + + + ); +}; + +interface MembersSectionProps { + users: UserData[]; + onAddUser: () => void; + onRemoveUser: (user: UserData) => void; +} + +const MembersSection: FC = ({ users, onAddUser, onRemoveUser }) => ( + + + Members + + + + {users.length === 0 ? ( + + No members assigned to this customer. + + ) : ( + + {users.map(user => ( + onRemoveUser(user)} + title='Remove member' + > + + + } + > + + + + + {user.loginName} + + } + secondary={user.fullName} + /> + + ))} + + )} + +); + +interface UsageStatsSectionProps { + usageStats: readonly UsageStats[]; + customer: Customer; + startDate: DateTime; + onStartDateChange: (date: DateTime) => void; +} + +const UsageStatsSection: FC = ({ usageStats, customer, startDate, onStartDateChange }) => ( + + + Usage Statistics + + + + +); + +export const CustomerDetails: FC = () => { + const { customer: customerName } = useParams<{ customer: string }>(); + const abortController = useRef(new AbortController()); + const { service } = useContext(MainContext); + + const [customer, setCustomer] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [formDialogOpen, setFormDialogOpen] = useState(false); + const [addUserDialogOpen, setAddUserDialogOpen] = useState(false); + + const { usageStats, error: statsError, startDate, setStartDate } = useUsageStats(customerName); + + const loadCustomer = useCallback(async () => { + if (!customerName) return; + try { + setLoading(true); + setError(null); + const data = await service.admin.getCustomer(abortController.current, customerName); + setCustomer(data); + } catch (err) { + if ((err as { status?: number })?.status === 404) { + setError(`Customer "${customerName}" not found.`); + } else { + setError(handleError(err as Error)); + } + } finally { + setLoading(false); + } + }, [service, customerName]); + + useEffect(() => { + loadCustomer(); + return () => abortController.current.abort(); + }, [loadCustomer]); + + const handleFormSubmit = async (updatedCustomer: Customer) => { + if (customer) { + await service.admin.updateCustomer(abortController.current, customer.name, updatedCustomer); + await loadCustomer(); + } + }; + + const users = customer?.users ?? []; + + // TODO: Replace with real API calls when backend is ready + const handleAddUser = (user: UserData) => { + }; + + const handleRemoveUser = (user: UserData) => { + }; + + if (loading) { + return ; + } + + if (error || statsError) { + return ; + } + + if (!customer) { + return null; + } + + return ( + <> + + + + + {customer.name} + + + + setFormDialogOpen(true)} /> + setAddUserDialogOpen(true)} onRemoveUser={handleRemoveUser} /> + + + + setFormDialogOpen(false)} + onSubmit={handleFormSubmit} + /> + + setAddUserDialogOpen(false)} + onAddUser={handleAddUser} + /> + + ); +}; diff --git a/webui/src/pages/admin-dashboard/customers/customers.tsx b/webui/src/pages/admin-dashboard/customers/customers.tsx index a4d9404ad..9224be5c4 100644 --- a/webui/src/pages/admin-dashboard/customers/customers.tsx +++ b/webui/src/pages/admin-dashboard/customers/customers.tsx @@ -21,18 +21,20 @@ import { Alert, IconButton, Stack, - Chip + Chip, + Avatar, + AvatarGroup } from "@mui/material"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import EditIcon from "@mui/icons-material/Edit"; import DeleteIcon from "@mui/icons-material/Delete"; import AddIcon from "@mui/icons-material/Add"; -import BarChartIcon from "@mui/icons-material/BarChart"; +import VisibilityIcon from "@mui/icons-material/Visibility"; import { MainContext } from "../../../context"; import type { Customer } from "../../../extension-registry-types"; import { CustomerFormDialog } from "./customer-form-dialog"; import { DeleteCustomerDialog } from "./delete-customer-dialog"; -import { handleError } from "../../../utils"; +import { createRoute, handleError } from "../../../utils"; import { createMultiSelectFilterOperators, createArrayContainsFilterOperators } from "../components"; import { AdminDashboardRoutes } from "../admin-dashboard"; import { Link } from "react-router-dom"; @@ -170,6 +172,25 @@ export const Customers: FC = () => { ); } }, + { + field: 'users', + headerName: 'Members', + minWidth: 140, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => { + const users = params.row.users ?? []; + return ( + + + {users.map((user) => ( + + ))} + + + ); + } + }, { field: 'actions', headerName: 'Actions', @@ -181,10 +202,10 @@ export const Customers: FC = () => { - + { ]; return ( - + { } }); export const PublisherAdmin: FunctionComponent = () => { + const { publisher: publisherParam } = useParams<{ publisher: string }>(); + const navigate = useNavigate(); const { pageSettings, service, user, handleError } = useContext(MainContext); const abortController = useRef(new AbortController()); @@ -28,24 +32,20 @@ export const PublisherAdmin: FunctionComponent = () => { }, []); const [loading, setLoading] = useState(false); - const [inputValue, setInputValue] = useState(''); - const onChangeInput = (name: string) => { - setInputValue(name); - }; const [publisher, setPublisher] = useState(); const [notFound, setNotFound] = useState(''); - const fetchPublisher = async () => { - const publisherName = inputValue; + + const fetchPublisher = useCallback(async (publisherName: string) => { try { setLoading(true); if (publisherName === '') { setNotFound(''); setPublisher(undefined); } else { - const publisher = await service.admin.getPublisherInfo(abortController.current, 'github', publisherName); + const pub = await service.admin.getPublisherInfo(abortController.current, 'github', publisherName); setNotFound(''); - setPublisher(publisher); + setPublisher(pub); } setLoading(false); } catch (err) { @@ -57,10 +57,26 @@ export const PublisherAdmin: FunctionComponent = () => { } setLoading(false); } + }, [service, handleError]); + + useEffect(() => { + if (publisherParam) { + fetchPublisher(publisherParam); + } + }, [publisherParam, fetchPublisher]); + + const handleSubmit = (inputValue: string) => { + if (inputValue) { + navigate(`${AdminDashboardRoutes.PUBLISHER_ADMIN}/${inputValue}`); + } else { + navigate(AdminDashboardRoutes.PUBLISHER_ADMIN); + } }; const handleUpdate = () => { - fetchPublisher(); + if (publisherParam) { + fetchPublisher(publisherParam); + } }; let listContainer: ReactNode = ''; @@ -78,7 +94,7 @@ export const PublisherAdmin: FunctionComponent = () => { return ] + [ {}} />] } listContainer={listContainer} loading={loading} diff --git a/webui/src/pages/admin-dashboard/usage-stats/usage-stats-chart.tsx b/webui/src/pages/admin-dashboard/usage-stats/usage-stats-chart.tsx index 38d0e468d..7db3a3c9d 100644 --- a/webui/src/pages/admin-dashboard/usage-stats/usage-stats-chart.tsx +++ b/webui/src/pages/admin-dashboard/usage-stats/usage-stats-chart.tsx @@ -17,12 +17,15 @@ import { Paper, Typography, Alert, - Stack + Stack, + IconButton } from "@mui/material"; import { BarPlot } from "@mui/x-charts/BarChart"; import { DatePicker } from "@mui/x-date-pickers/DatePicker"; import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon"; +import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; import type { Customer, UsageStats } from "../../../extension-registry-types"; import { ChartsReferenceLine, @@ -38,13 +41,15 @@ interface UsageStatsChartProps { customer: Customer | null; startDate: DateTime; onStartDateChange: (date: DateTime) => void; + embedded?: boolean; } export const UsageStatsChart: FC = ({ usageStats, customer, startDate, - onStartDateChange + onStartDateChange, + embedded = false }) => { const dayStart = startDate.startOf('day').toMillis() / 1000; const dayEnd = startDate.endOf('day').toMillis() / 1000; @@ -91,9 +96,11 @@ export const UsageStatsChart: FC = ({ [usageStats] ); + const Wrapper: typeof Box = embedded ? Box : Paper; + return ( - + Filters @@ -105,14 +112,20 @@ export const UsageStatsChart: FC = ({ timezone='UTC' slotProps={{ textField: { size: 'small' }, actionBar: { actions: ['today'] } }} /> + onStartDateChange(startDate.minus({ days: 1 }))}> + + + onStartDateChange(startDate.plus({ days: 1 }))}> + + - + {usageStats.length === 0 ? No usage data available for this customer. : <> - + = ({ /> - + diff --git a/webui/src/pages/admin-dashboard/usage-stats/usage-stats.tsx b/webui/src/pages/admin-dashboard/usage-stats/usage-stats.tsx index 96be189f6..adbbdb47e 100644 --- a/webui/src/pages/admin-dashboard/usage-stats/usage-stats.tsx +++ b/webui/src/pages/admin-dashboard/usage-stats/usage-stats.tsx @@ -11,18 +11,17 @@ * SPDX-License-Identifier: EPL-2.0 *****************************************************************************/ -import { FC, useContext, useState, useEffect, useRef, useMemo, useCallback } from "react"; +import { FC, useContext, useState, useEffect, useRef, useMemo } from "react"; import { Box, Alert } from "@mui/material"; import { useParams, useNavigate } from "react-router-dom"; import { MainContext } from "../../../context"; -import type { UsageStats, Customer } from "../../../extension-registry-types"; +import type { Customer } from "../../../extension-registry-types"; import { handleError } from "../../../utils"; import { AdminDashboardRoutes } from "../admin-dashboard"; import { SearchListContainer } from "../search-list-container"; import { CustomerSearch } from "./usage-stats-search"; import { UsageStatsChart } from "./usage-stats-chart"; -import { getDefaultStartDate } from "./usage-stats-utils"; -import { DateTime } from "luxon"; +import { useUsageStats } from "./use-usage-stats"; export const UsageStatsView: FC = () => { const { customer } = useParams<{ customer: string }>(); @@ -32,10 +31,9 @@ export const UsageStatsView: FC = () => { const [customers, setCustomers] = useState([]); const [customersLoading, setCustomersLoading] = useState(true); - const [usageStats, setUsageStats] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [startDate, setStartDate] = useState(getDefaultStartDate); + const [customersError, setCustomersError] = useState(null); + + const { usageStats, loading, error: statsError, startDate, setStartDate } = useUsageStats(customer); // Load customers for autocomplete useEffect(() => { @@ -45,7 +43,7 @@ export const UsageStatsView: FC = () => { const data = await service.admin.getCustomers(abortController.current); setCustomers(data.customers); } catch (err) { - setError(handleError(err as Error)); + setCustomersError(handleError(err as Error)); } finally { setCustomersLoading(false); } @@ -67,35 +65,7 @@ export const UsageStatsView: FC = () => { } }; - const loadUsageStats = useCallback(async () => { - if (!customer) { - setUsageStats([]); - setLoading(false); - return; - } - - try { - setLoading(true); - setError(null); - const data = await service.admin.getUsageStats( - abortController.current, - customer, - startDate.toJSDate() - ); - setUsageStats(data.stats); - } catch (err) { - setError(handleError(err as Error)); - } finally { - setLoading(false); - } - }, [service, customer, startDate]); - - useEffect(() => { - if (customer) { - loadUsageStats(); - } - }, [loadUsageStats, customer]); - + const error = customersError || statsError; if (error) { return {error}; } diff --git a/webui/src/pages/admin-dashboard/usage-stats/use-usage-stats.ts b/webui/src/pages/admin-dashboard/usage-stats/use-usage-stats.ts new file mode 100644 index 000000000..fa6d07ef7 --- /dev/null +++ b/webui/src/pages/admin-dashboard/usage-stats/use-usage-stats.ts @@ -0,0 +1,70 @@ +/****************************************************************************** + * 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, useState, useEffect, useRef, useCallback } from "react"; +import { MainContext } from "../../../context"; +import type { UsageStats } from "../../../extension-registry-types"; +import { handleError } from "../../../utils"; +import { getDefaultStartDate } from "./usage-stats-utils"; +import { DateTime } from "luxon"; + +export const useUsageStats = (customerName: string | undefined) => { + const abortController = useRef(new AbortController()); + const { service } = useContext(MainContext); + + const [usageStats, setUsageStats] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [internalStartDate, setInternalStartDate] = useState(getDefaultStartDate); + + const startDateRef = useRef(internalStartDate); + startDateRef.current = internalStartDate; + + const fetchUsageStats = useCallback(async (date: DateTime) => { + if (!customerName) { + setUsageStats([]); + setLoading(false); + return; + } + + try { + setLoading(true); + setError(null); + const data = await service.admin.getUsageStats( + abortController.current, + customerName, + date.toJSDate() + ); + setUsageStats(data.stats); + } catch (err) { + setError(handleError(err as Error)); + } finally { + setLoading(false); + } + }, [service, customerName]); + + const setStartDate = useCallback((date: DateTime) => { + setInternalStartDate(date); + fetchUsageStats(date); + }, [fetchUsageStats]); + + useEffect(() => { + fetchUsageStats(startDateRef.current); + return () => { + abortController.current.abort(); + abortController.current = new AbortController(); + }; + }, [fetchUsageStats]); + + return { usageStats, loading, error, startDate: internalStartDate, setStartDate }; +}; diff --git a/webui/src/pages/user/add-namespace-member-dialog.tsx b/webui/src/pages/user/add-namespace-member-dialog.tsx index 292e603eb..56d8397c6 100644 --- a/webui/src/pages/user/add-namespace-member-dialog.tsx +++ b/webui/src/pages/user/add-namespace-member-dialog.tsx @@ -8,15 +8,12 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import { ChangeEvent, FunctionComponent, KeyboardEvent, useState, useContext, useEffect, useRef } from 'react'; +import { FunctionComponent, useContext, useRef } from 'react'; import { UserData } from '../..'; -import { - Dialog, DialogTitle, DialogContent, DialogContentText, TextField, DialogActions, Button, Popper, Fade, Paper, - Box, Avatar -} from '@mui/material'; import { Namespace, NamespaceMembership, isError } from '../../extension-registry-types'; import { NamespaceDetailConfigContext } from './user-settings-namespace-detail'; import { MainContext } from '../../context'; +import { AddUserDialog } from './add-user-dialog'; export interface AddMemberDialogProps { open: boolean; @@ -28,29 +25,17 @@ export interface AddMemberDialogProps { } export const AddMemberDialog: FunctionComponent = props => { - const { open } = props; const config = useContext(NamespaceDetailConfigContext); const { service, handleError } = useContext(MainContext); - const [foundUsers, setFoundUsers] = useState([]); - const [showUserPopper, setShowUserPopper] = useState(false); - const [popperTarget, setPopperTarget] = useState(undefined); const abortController = useRef(new AbortController()); - useEffect(() => { - return () => { - abortController.current.abort(); - }; - }, []); - const addUser = async (user: UserData) => { + const existingUsers = props.members.map(m => m.user); + + const handleAddUser = async (user: UserData) => { try { if (!props.namespace) { return; } - if (props.members.find(m => m.user.loginName === user.loginName && m.user.provider === user.provider)) { - setShowUserPopper(false); - handleError({ message: `User ${user.loginName} is already a member of ${props.namespace.name}.` }); - return; - } props.setLoadingState(true); const endpoint = props.namespace.roleUrl; const result = await service.setNamespaceMember(abortController.current, endpoint, user, config.defaultMemberRole ?? 'contributor'); @@ -58,106 +43,22 @@ export const AddMemberDialog: FunctionComponent = props => throw result; } props.setLoadingState(false); - onClose(); + props.onClose(); } catch (err) { - setShowUserPopper(false); props.setLoadingState(false); handleError(err); } }; - const onClose = () => { - setShowUserPopper(false); - props.onClose(); - }; - - const handleUserSearch = async (e: ChangeEvent) => { - const popperTarget = e.currentTarget; - setPopperTarget(popperTarget); - const val = popperTarget.value; - let showUserPopper = false; - let foundUsers: UserData[] = []; - if (val) { - const users = await service.getUserByName(abortController.current, val); - if (users) { - showUserPopper = true; - foundUsers = users; - } - } - setShowUserPopper(showUserPopper); - setFoundUsers(foundUsers); - }; - - return <> - - Add User to Namespace - - - Enter the Login Name of the User you want to add. - - { - if (e.key === "Enter" && foundUsers.length === 1) { - e.preventDefault(); - addUser(foundUsers[0]); - } - }} - /> - - - - - - - {({ TransitionProps }) => ( - - - { - foundUsers.filter(props.filterUsers).map(foundUser => { - return addUser(foundUser)} - key={'found' + foundUser.loginName} - sx={{ - display: 'flex', - height: 60, - alignItems: 'center', - '&:hover': { - cursor: 'pointer', - bgcolor: 'action.hover' - } - }} - > - - - {foundUser.loginName} - - - {foundUser.fullName} - - - - - - ; - }) - } - - - )} - - ; + return ( + + ); }; \ No newline at end of file diff --git a/webui/src/pages/user/add-user-dialog.tsx b/webui/src/pages/user/add-user-dialog.tsx new file mode 100644 index 000000000..ed477ebfc --- /dev/null +++ b/webui/src/pages/user/add-user-dialog.tsx @@ -0,0 +1,141 @@ +/****************************************************************************** + * 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 { FC, useState, useContext, useEffect, useRef } from 'react'; +import { + Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button, + TextField, Autocomplete, Box, Avatar +} from '@mui/material'; +import type { UserData } from '../../extension-registry-types'; +import { MainContext } from '../../context'; + +export interface AddUserDialogProps { + open: boolean; + title: string; + description: string; + existingUsers: UserData[]; + onClose: () => void; + onAddUser: (user: UserData) => void; + filterUsers?: (user: UserData) => boolean; +} + +export const AddUserDialog: FC = ({ + open, + title, + description, + existingUsers, + onClose, + onAddUser, + filterUsers: externalFilter +}) => { + const { service, handleError } = useContext(MainContext); + const [options, setOptions] = useState([]); + const [loading, setLoading] = useState(false); + const abortController = useRef(new AbortController()); + const debounceTimeout = useRef>(); + + useEffect(() => { + return () => { + abortController.current.abort(); + clearTimeout(debounceTimeout.current); + }; + }, []); + + const isUserExcluded = (user: UserData) => + existingUsers.some(u => u.loginName === user.loginName && u.provider === user.provider) + || (externalFilter && !externalFilter(user)); + + const handleInputChange = (_: unknown, value: string) => { + clearTimeout(debounceTimeout.current); + if (!value) { + setOptions([]); + setLoading(false); + return; + } + setLoading(true); + debounceTimeout.current = setTimeout(async () => { + const users = await service.getUserByName(abortController.current, value); + if (users) { + setOptions(users); + } + setLoading(false); + }, 300); + }; + + const handleSelect = (_: unknown, user: UserData | null) => { + if (!user) return; + if (isUserExcluded(user)) { + handleError({ message: `User ${user.loginName} is already added.` }); + return; + } + onAddUser(user); + onClose(); + }; + + const handleClose = () => { + setOptions([]); + onClose(); + }; + + return ( + + {title} + + {description} + + options={options} + loading={loading} + filterOptions={(opts) => opts.filter(u => !isUserExcluded(u))} + getOptionLabel={(option) => option.loginName} + isOptionEqualToValue={(option, value) => + option.loginName === value.loginName && option.provider === value.provider + } + onInputChange={handleInputChange} + onChange={handleSelect} + renderOption={(props, user) => ( + + + + {user.loginName} + {user.fullName} + + + )} + renderInput={(params) => ( + + )} + /> + + + + + + ); +}; From 991e5244bb1d8d6229d327906fe92ee643aecade Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Thu, 19 Mar 2026 23:39:54 +0100 Subject: [PATCH 04/18] fix navigation in sidepanel --- webui/src/components/sidepanel/navigation-item.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webui/src/components/sidepanel/navigation-item.tsx b/webui/src/components/sidepanel/navigation-item.tsx index 1d5805de0..ec81f2409 100644 --- a/webui/src/components/sidepanel/navigation-item.tsx +++ b/webui/src/components/sidepanel/navigation-item.tsx @@ -21,7 +21,7 @@ export const NavigationItem: FunctionComponent Date: Thu, 19 Mar 2026 23:40:37 +0100 Subject: [PATCH 05/18] overhaul admin dashboard: make sidepanel collabsible, add breadcrumbs --- webui/src/components/sidepanel/sidepanel.tsx | 55 ++-- .../pages/admin-dashboard/admin-dashboard.tsx | 274 +++++++++++++----- .../customers/customer-details.tsx | 19 +- .../admin-dashboard/customers/customers.tsx | 2 +- 4 files changed, 245 insertions(+), 105 deletions(-) diff --git a/webui/src/components/sidepanel/sidepanel.tsx b/webui/src/components/sidepanel/sidepanel.tsx index 342b8ab7a..cacf330cc 100644 --- a/webui/src/components/sidepanel/sidepanel.tsx +++ b/webui/src/components/sidepanel/sidepanel.tsx @@ -9,30 +9,51 @@ ********************************************************************************/ import { FunctionComponent, PropsWithChildren } from 'react'; -import { Drawer, List } from '@mui/material'; -import { Theme } from '@mui/material/styles'; +import { Divider, Drawer, IconButton, List } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; + +export const DrawerHeader = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + padding: theme.spacing(0, 1), + // necessary for content to be below app bar + ...theme.mixins.toolbar, + justifyContent: 'flex-end', +})); + +export const Sidepanel: FunctionComponent> = props => { + const width = props.width; -export const Sidepanel: FunctionComponent = props => { return ( ({ + sx={{ + width: width, + flexShrink: 0, '& .MuiDrawer-paper': { - position: 'relative', - justifyContent: 'space-between', - transition: theme.transitions.create('width', { - easing: theme.transitions.easing.sharp, - duration: theme.transitions.duration.enteringScreen - }), - overflowX: { xs: 'hidden', sm: 'hidden', md: 'none', lg: 'none', xl: 'none' }, - width: { xs: theme.spacing(7) + 1, sm: theme.spacing(9) + 1, md: 240 }, - } - })} + width: width, + boxSizing: 'border-box', + }, + }} + variant='persistent' + anchor='left' + open={props.open} > + + + + + + {props.children} ); -}; \ No newline at end of file +}; + +interface SidepanelProps { + width: number; + open: boolean; + handleDrawerClose: () => void; +} diff --git a/webui/src/pages/admin-dashboard/admin-dashboard.tsx b/webui/src/pages/admin-dashboard/admin-dashboard.tsx index d38f9f361..1489f00aa 100644 --- a/webui/src/pages/admin-dashboard/admin-dashboard.tsx +++ b/webui/src/pages/admin-dashboard/admin-dashboard.tsx @@ -9,33 +9,46 @@ ********************************************************************************/ import { FunctionComponent, ReactNode, useContext, useState } from 'react'; -import { Box, Container, CssBaseline, Typography, IconButton } from '@mui/material'; -import { createRoute } from '../../utils'; -import { Sidepanel } from '../../components/sidepanel/sidepanel'; -import { NavigationItem } from '../../components/sidepanel/navigation-item'; +import { + Box, + Container, + CssBaseline, + Typography, + IconButton, + Breadcrumbs, + LinkProps, + Link, + Toolbar +} from '@mui/material'; +import MuiAppBar, { AppBarProps as MuiAppBarProps } from '@mui/material/AppBar'; +import { styled } from "@mui/material/styles"; +import { Link as RouterLink, Route, Routes, useNavigate, useLocation } from 'react-router-dom'; +import AccountBoxIcon from '@mui/icons-material/AccountBox'; import AssignmentIndIcon from '@mui/icons-material/AssignmentInd'; +import BarChartIcon from '@mui/icons-material/BarChart'; import ExtensionSharpIcon from '@mui/icons-material/ExtensionSharp'; -import { Route, Routes, useNavigate, useLocation } from 'react-router-dom'; -import { NamespaceAdmin } from './namespace-admin'; -import { ExtensionAdmin } from './extension-admin'; -import { MainContext } from '../../context'; import HighlightOffIcon from '@mui/icons-material/HighlightOff'; -import { Welcome } from './welcome'; -import { PublisherAdmin } from './publisher-admin'; -import PersonIcon from '@mui/icons-material/Person'; +import HistoryIcon from '@mui/icons-material/History'; +import MenuIcon from "@mui/icons-material/Menu"; import PeopleIcon from '@mui/icons-material/People'; -import { ScanAdmin } from './scan-admin'; +import PersonIcon from '@mui/icons-material/Person'; import SecurityIcon from '@mui/icons-material/Security'; 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 { CustomerDetails } from './customers/customer-details'; -import { UsageStatsView } from './usage-stats/usage-stats'; -import { Logs } from './logs/logs'; +import { Customers } from './customers/customers'; +import { DrawerHeader, Sidepanel } from "../../components/sidepanel/sidepanel"; +import { ExtensionAdmin } from './extension-admin'; import { LoginComponent } from "../../default/login"; -import AccountBoxIcon from '@mui/icons-material/AccountBox'; +import { Logs } from './logs/logs'; +import { MainContext } from '../../context'; +import { NamespaceAdmin } from './namespace-admin'; +import { NavigationItem } from "../../components/sidepanel/navigation-item"; +import { PublisherAdmin } from './publisher-admin'; +import { ScanAdmin } from './scan-admin'; +import { Tiers } from './tiers/tiers'; +import { UsageStatsView } from './usage-stats/usage-stats'; +import { Welcome } from './welcome'; +import { createRoute } from '../../utils'; export namespace AdminDashboardRoutes { export const ROOT = 'admin-dashboard'; @@ -61,68 +74,189 @@ const Message: FunctionComponent<{message: string}> = ({ message }) => { ); }; +const routes: { [key: string]: { name: string; component: ReactNode; icon?: ReactNode } } = {}; +routes[AdminDashboardRoutes.MAIN] = { name: 'Admin Dashboard', component: }; +routes[AdminDashboardRoutes.NAMESPACE_ADMIN] = { name: 'Namespaces', component: , icon: }; +routes[AdminDashboardRoutes.EXTENSION_ADMIN] = { name: 'Extensions', component: , icon: }; +routes[AdminDashboardRoutes.PUBLISHER_ADMIN] = { name: 'Publisher', component: , icon: }; +routes[AdminDashboardRoutes.SCANS_ADMIN] = { name: 'Scans', component: , icon: }; +routes[AdminDashboardRoutes.TIERS] = { name: 'Tiers', component: , icon: }; +routes[AdminDashboardRoutes.CUSTOMERS] = { name: 'Customers', component: , icon: }; +routes[AdminDashboardRoutes.USAGE_STATS] = { name: 'Usage Stats', component: , icon: }; +routes[AdminDashboardRoutes.LOGS] = { name: 'Logs', component: , icon: }; + +const drawerWidth = 240; + +interface AppBarProps extends MuiAppBarProps { + open?: boolean; +} + +const AppBar = styled(MuiAppBar, { + shouldForwardProp: (prop) => prop !== 'open', +})(({ theme }) => ({ + transition: theme.transitions.create(['margin', 'width'], { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + variants: [ + { + props: ({ open }) => open, + style: { + width: `calc(100% - ${drawerWidth}px)`, + marginLeft: `${drawerWidth}px`, + transition: theme.transitions.create(['margin', 'width'], { + easing: theme.transitions.easing.easeOut, + duration: theme.transitions.duration.enteringScreen, + }), + }, + }, + ], +})); + +interface LinkRouterProps extends LinkProps { + to: string; + replace?: boolean; +} + +const LinkRouter = (props: LinkRouterProps) => ( + +); + +const BreadcrumbsComponent = () => { + const { pathname } = useLocation(); + + const pathnames = pathname.split("/").filter((segment) => segment); + + return ( + + + Home + + {pathnames.map((value, index) => { + const last = index === pathnames.length - 1; + const to = `/${pathnames.slice(0, index + 1).join("/")}`; + + return last ? ( + + {routes[to]?.name ?? value} + + ) : ( + + {routes[to]?.name} + + ); + })} + + ); +}; + +const Main = styled('main', { shouldForwardProp: (prop) => prop !== 'open' })<{ + open?: boolean; +}>(({ theme }) => ({ + flexGrow: 1, + padding: theme.spacing(3), + transition: theme.transitions.create('margin', { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + marginLeft: `-${drawerWidth}px`, + variants: [ + { + props: ({ open }) => open, + style: { + transition: theme.transitions.create('margin', { + easing: theme.transitions.easing.easeOut, + duration: theme.transitions.duration.enteringScreen, + }), + marginLeft: 0, + }, + }, + ], +})); + export const AdminDashboard: FunctionComponent = props => { const { user, loginProviders } = useContext(MainContext); + const [drawerOpen, setDrawerOpen] = useState(false); const navigate = useNavigate(); const toMainPage = () => navigate('/'); const [currentPage, setCurrentPage] = useState(useLocation().pathname); - const handleOpenRoute = (route: string) => setCurrentPage(route); + const handleOpenRoute = (route: string) => { + setCurrentPage(route); + }; let content: ReactNode = null; if (user?.role === 'admin') { content = <> - - } route={AdminDashboardRoutes.NAMESPACE_ADMIN} /> - } route={AdminDashboardRoutes.EXTENSION_ADMIN} /> - } route={AdminDashboardRoutes.PUBLISHER_ADMIN} /> - } route={AdminDashboardRoutes.SCANS_ADMIN} /> - } route={AdminDashboardRoutes.TIERS} /> - } route={AdminDashboardRoutes.CUSTOMERS} /> - } route={AdminDashboardRoutes.USAGE_STATS} /> - } route={AdminDashboardRoutes.LOGS} /> - - - - - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + + + + + setDrawerOpen(true)} + edge='start' + sx={[{ mr: 2 }, drawerOpen && { display: 'none' }]} + > + + + + + + + + + + + setDrawerOpen(false)} > + {Object.keys(routes).map((key, i) => ( + routes[key].icon && + + ))} + +
+ + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + +
; } else if (user) { diff --git a/webui/src/pages/admin-dashboard/customers/customer-details.tsx b/webui/src/pages/admin-dashboard/customers/customer-details.tsx index ddfc608e0..8ccce1c79 100644 --- a/webui/src/pages/admin-dashboard/customers/customer-details.tsx +++ b/webui/src/pages/admin-dashboard/customers/customer-details.tsx @@ -17,8 +17,6 @@ import { Typography, Paper, type PaperProps, - type SxProps, - type Theme, Chip, Stack, Alert, @@ -33,11 +31,10 @@ import { Grid, LinearProgress } from "@mui/material"; -import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import EditIcon from "@mui/icons-material/Edit"; import PersonAddIcon from "@mui/icons-material/PersonAdd"; import DeleteIcon from "@mui/icons-material/Delete"; -import { useParams, useNavigate, Link as RouterLink } from "react-router-dom"; +import { useParams, Link as RouterLink } from "react-router-dom"; import { MainContext } from "../../../context"; import type { Customer, UserData, UsageStats } from "../../../extension-registry-types"; import type { DateTime } from "luxon"; @@ -50,25 +47,14 @@ import { AddUserDialog } from "../../user/add-user-dialog"; const sectionPaperProps: PaperProps = { elevation: 1, sx: { p: 3, mb: 3 } }; -const BackToCustomersButton: FC<{ sx?: SxProps }> = ({ sx }) => { - const navigate = useNavigate(); - return ( - - ); -}; - const CustomerDetailsLoading: FC = () => ( - ); const CustomerDetailsError: FC<{ message: string }> = ({ message }) => ( - {message} ); @@ -302,9 +288,8 @@ export const CustomerDetails: FC = () => { return ( <> - + - {customer.name} diff --git a/webui/src/pages/admin-dashboard/customers/customers.tsx b/webui/src/pages/admin-dashboard/customers/customers.tsx index 9224be5c4..a6ac9e11e 100644 --- a/webui/src/pages/admin-dashboard/customers/customers.tsx +++ b/webui/src/pages/admin-dashboard/customers/customers.tsx @@ -228,7 +228,7 @@ export const Customers: FC = () => { ]; return ( - + Customer Management From df1733349560a75a5ca4eaac1e2317d483267cc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez=20Hidalgo?= <31970428+gnugomez@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:00:49 +0100 Subject: [PATCH 06/18] fix: app bar not showing proper bg color on dark mode (#1704) --- webui/src/pages/admin-dashboard/admin-dashboard.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/webui/src/pages/admin-dashboard/admin-dashboard.tsx b/webui/src/pages/admin-dashboard/admin-dashboard.tsx index 1489f00aa..7238693da 100644 --- a/webui/src/pages/admin-dashboard/admin-dashboard.tsx +++ b/webui/src/pages/admin-dashboard/admin-dashboard.tsx @@ -188,11 +188,11 @@ export const AdminDashboard: FunctionComponent = props => { let content: ReactNode = null; if (user?.role === 'admin') { - content = <> + content = - - + + setDrawerOpen(true)} @@ -257,8 +257,7 @@ export const AdminDashboard: FunctionComponent = props => { - - ; + ; } else if (user) { content = ; } else if (!props.userLoading && loginProviders) { From fa79b9ce8bf8f48687908e0704820dbcdfa6c8e9 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Fri, 20 Mar 2026 11:10:55 +0100 Subject: [PATCH 07/18] make sidebar open by default --- webui/src/pages/admin-dashboard/admin-dashboard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webui/src/pages/admin-dashboard/admin-dashboard.tsx b/webui/src/pages/admin-dashboard/admin-dashboard.tsx index 7238693da..bf5b82482 100644 --- a/webui/src/pages/admin-dashboard/admin-dashboard.tsx +++ b/webui/src/pages/admin-dashboard/admin-dashboard.tsx @@ -176,7 +176,7 @@ const Main = styled('main', { shouldForwardProp: (prop) => prop !== 'open' })<{ export const AdminDashboard: FunctionComponent = props => { const { user, loginProviders } = useContext(MainContext); - const [drawerOpen, setDrawerOpen] = useState(false); + const [drawerOpen, setDrawerOpen] = useState(true); const navigate = useNavigate(); const toMainPage = () => navigate('/'); From b4d2ea2f958cf870ec643a6d0d49884e00294f3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez=20Hidalgo?= <31970428+gnugomez@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:53:02 +0100 Subject: [PATCH 08/18] feat: adding sidebar groups (#1705) --- .../components/sidepanel/navigation-item.tsx | 3 +- .../pages/admin-dashboard/admin-dashboard.tsx | 87 +++++++++++++++---- 2 files changed, 72 insertions(+), 18 deletions(-) diff --git a/webui/src/components/sidepanel/navigation-item.tsx b/webui/src/components/sidepanel/navigation-item.tsx index ec81f2409..8e08c083e 100644 --- a/webui/src/components/sidepanel/navigation-item.tsx +++ b/webui/src/components/sidepanel/navigation-item.tsx @@ -11,6 +11,7 @@ import { FunctionComponent, PropsWithChildren, ReactNode, useState } from 'react'; import { ListItemButton, ListItemText, Collapse, List, ListItemIcon } from '@mui/material'; import ExpandLess from '@mui/icons-material/ExpandLess'; +import ExpandMore from '@mui/icons-material/ExpandMore'; import { useNavigate } from 'react-router'; export const NavigationItem: FunctionComponent> = props => { @@ -32,7 +33,7 @@ export const NavigationItem: FunctionComponent{props.icon} } - {props.children && open && } + {props.children && (open ? : )} { props.children && diff --git a/webui/src/pages/admin-dashboard/admin-dashboard.tsx b/webui/src/pages/admin-dashboard/admin-dashboard.tsx index bf5b82482..8a59bc8ae 100644 --- a/webui/src/pages/admin-dashboard/admin-dashboard.tsx +++ b/webui/src/pages/admin-dashboard/admin-dashboard.tsx @@ -33,6 +33,7 @@ import MenuIcon from "@mui/icons-material/Menu"; import PeopleIcon from '@mui/icons-material/People'; import PersonIcon from '@mui/icons-material/Person'; import SecurityIcon from '@mui/icons-material/Security'; +import SpeedIcon from '@mui/icons-material/Speed'; import StarIcon from '@mui/icons-material/Star'; import { CustomerDetails } from './customers/customer-details'; import { Customers } from './customers/customers'; @@ -74,16 +75,53 @@ const Message: FunctionComponent<{message: string}> = ({ message }) => { ); }; -const routes: { [key: string]: { name: string; component: ReactNode; icon?: ReactNode } } = {}; -routes[AdminDashboardRoutes.MAIN] = { name: 'Admin Dashboard', component: }; -routes[AdminDashboardRoutes.NAMESPACE_ADMIN] = { name: 'Namespaces', component: , icon: }; -routes[AdminDashboardRoutes.EXTENSION_ADMIN] = { name: 'Extensions', component: , icon: }; -routes[AdminDashboardRoutes.PUBLISHER_ADMIN] = { name: 'Publisher', component: , icon: }; -routes[AdminDashboardRoutes.SCANS_ADMIN] = { name: 'Scans', component: , icon: }; -routes[AdminDashboardRoutes.TIERS] = { name: 'Tiers', component: , icon: }; -routes[AdminDashboardRoutes.CUSTOMERS] = { name: 'Customers', component: , icon: }; -routes[AdminDashboardRoutes.USAGE_STATS] = { name: 'Usage Stats', component: , icon: }; -routes[AdminDashboardRoutes.LOGS] = { name: 'Logs', component: , icon: }; +interface RouteEntry { + path: string; + name: string; + icon: ReactNode; +} + +interface NavGroup { + name: string; + icon: ReactNode; + children: RouteEntry[]; +} + +type NavEntry = RouteEntry | NavGroup; + +const isNavGroup = (entry: NavEntry): entry is NavGroup => 'children' in entry; + +const navConfig: NavEntry[] = [ + { path: AdminDashboardRoutes.NAMESPACE_ADMIN, name: 'Namespaces', icon: }, + { path: AdminDashboardRoutes.EXTENSION_ADMIN, name: 'Extensions', icon: }, + { path: AdminDashboardRoutes.PUBLISHER_ADMIN, name: 'Publisher', icon: }, + { path: AdminDashboardRoutes.SCANS_ADMIN, name: 'Scans', icon: }, + { + name: 'Rate Limiting', + icon: , + children: [ + { path: AdminDashboardRoutes.TIERS, name: 'Tiers', icon: }, + { path: AdminDashboardRoutes.CUSTOMERS, name: 'Customers', icon: }, + { path: AdminDashboardRoutes.USAGE_STATS, name: 'Usage Stats', icon: }, + ], + }, + { path: AdminDashboardRoutes.LOGS, name: 'Logs', icon: }, +]; + +// Flat name lookup for breadcrumbs +const routeNames: { [key: string]: string } = { + [AdminDashboardRoutes.MAIN]: 'Admin Dashboard', + ...navConfig.reduce<{ [key: string]: string }>((acc, entry) => { + if (isNavGroup(entry)) { + entry.children.forEach(child => { + acc[child.path] = child.name; + }); + } else { + acc[entry.path] = entry.name; + } + return acc; + }, {}), +}; const drawerWidth = 240; @@ -138,11 +176,11 @@ const BreadcrumbsComponent = () => { return last ? ( - {routes[to]?.name ?? value} + {routeNames[to] ?? value} ) : ( - {routes[to]?.name} + {routeNames[to]} ); })} @@ -210,11 +248,26 @@ export const AdminDashboard: FunctionComponent = props => { setDrawerOpen(false)} > - {Object.keys(routes).map((key, i) => ( - routes[key].icon && - - ))} + {navConfig.map((entry) => { + if (isNavGroup(entry)) { + return ( + + {entry.children.map((child) => ( + + ))} + + ); + } + return ( + + ); + })}
From f6e13bd05165b2e0febd7e447c982ea6b67ec052 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Fri, 20 Mar 2026 12:56:00 +0100 Subject: [PATCH 09/18] add eslint plugin for react refresh, fix and update target --- webui/configs/base.tsconfig.json | 6 ++-- webui/eslint.config.mjs | 5 ++- webui/package.json | 1 + .../scan-admin/scan-card/scan-card-header.tsx | 4 +-- .../components/sidepanel/drawer-header.tsx | 23 ++++++++++++ webui/src/components/sidepanel/sidepanel.tsx | 13 ++----- webui/src/default/default-app.tsx | 2 +- webui/src/default/menu-content.tsx | 4 +-- webui/src/default/page-settings.tsx | 2 +- webui/src/main.tsx | 3 +- webui/src/other-pages.tsx | 25 ++++++++++--- .../pages/admin-dashboard/admin-dashboard.tsx | 22 +++--------- .../src/pages/admin-dashboard/admin-routes.ts | 27 ++++++++++++++ .../customers/customer-details.tsx | 2 +- .../admin-dashboard/customers/customers.tsx | 2 +- .../pages/admin-dashboard/publisher-admin.tsx | 2 +- .../usage-stats/usage-stats.tsx | 2 +- webui/src/pages/admin-dashboard/welcome.tsx | 2 +- .../extension-detail-overview.tsx | 4 +-- .../extension-detail-routes.ts | 35 +++++++++++++++++++ .../extension-detail/extension-detail.tsx | 26 ++------------ .../extension-list-container.tsx | 6 +--- .../extension-list/extension-list-item.tsx | 2 +- .../extension-list/extension-list-routes.ts | 18 ++++++++++ .../namespace-detail-routes.ts | 23 ++++++++++++ .../namespace-detail/namespace-detail.tsx | 10 ------ webui/src/pages/user/avatar.tsx | 4 +-- .../user-namespace-extension-list-item.tsx | 4 +-- webui/src/pages/user/user-setting-tabs.tsx | 2 +- .../user/user-settings-delete-extension.tsx | 2 +- .../user/user-settings-namespace-detail.tsx | 2 +- webui/src/pages/user/user-settings-routes.ts | 24 +++++++++++++ webui/src/pages/user/user-settings-tokens.tsx | 9 +---- webui/src/pages/user/user-settings.tsx | 11 ------ webui/yarn.lock | 10 ++++++ 35 files changed, 224 insertions(+), 115 deletions(-) create mode 100644 webui/src/components/sidepanel/drawer-header.tsx create mode 100644 webui/src/pages/admin-dashboard/admin-routes.ts create mode 100644 webui/src/pages/extension-detail/extension-detail-routes.ts create mode 100644 webui/src/pages/extension-list/extension-list-routes.ts create mode 100644 webui/src/pages/namespace-detail/namespace-detail-routes.ts create mode 100644 webui/src/pages/user/user-settings-routes.ts diff --git a/webui/configs/base.tsconfig.json b/webui/configs/base.tsconfig.json index 022c715c5..b7a4b89dc 100644 --- a/webui/configs/base.tsconfig.json +++ b/webui/configs/base.tsconfig.json @@ -1,11 +1,11 @@ { "compilerOptions": { - "target": "es6", - "module": "es6", + "target": "es2020", + "module": "es2020", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, "lib": [ - "es6", "es2020.string", "dom" + "es2020", "dom" ], "typeRoots": [ "node_modules/@types", "typings" diff --git a/webui/eslint.config.mjs b/webui/eslint.config.mjs index 39577ff64..eb275b580 100644 --- a/webui/eslint.config.mjs +++ b/webui/eslint.config.mjs @@ -6,6 +6,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import js from "@eslint/js"; import { FlatCompat } from "@eslint/eslintrc"; +import { reactRefresh } from "eslint-plugin-react-refresh"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -20,7 +21,9 @@ export default [...compat.extends( "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended", "plugin:react/recommended", -), { +), + reactRefresh.configs.vite(), +{ files: ["**/*.ts", "**/*.tsx"], plugins: { "@typescript-eslint": typescriptEslint, diff --git a/webui/package.json b/webui/package.json index a9c05db36..d07e7772c 100644 --- a/webui/package.json +++ b/webui/package.json @@ -102,6 +102,7 @@ "chai": "^4.3.0", "eslint": "^9.39.0", "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-refresh": "^0.5.2", "express": "^4.21.0", "express-rate-limit": "^7.4.0", "mocha": "^11.7.0", diff --git a/webui/src/components/scan-admin/scan-card/scan-card-header.tsx b/webui/src/components/scan-admin/scan-card/scan-card-header.tsx index c03a7f0fc..2e22e0c5c 100644 --- a/webui/src/components/scan-admin/scan-card/scan-card-header.tsx +++ b/webui/src/components/scan-admin/scan-card/scan-card-header.tsx @@ -32,8 +32,8 @@ import { getStatusColorSx, } from './utils'; import { createRoute } from '../../../utils'; -import { AdminDashboardRoutes } from '../../../pages/admin-dashboard/admin-dashboard'; -import { ExtensionDetailRoutes } from '../../../pages/extension-detail/extension-detail'; +import { AdminDashboardRoutes } from '../../../pages/admin-dashboard/admin-routes'; +import { ExtensionDetailRoutes } from '../../../pages/extension-detail/extension-detail-routes'; interface ScanCardHeaderProps { scan: ScanResult; diff --git a/webui/src/components/sidepanel/drawer-header.tsx b/webui/src/components/sidepanel/drawer-header.tsx new file mode 100644 index 000000000..4114710b7 --- /dev/null +++ b/webui/src/components/sidepanel/drawer-header.tsx @@ -0,0 +1,23 @@ +/****************************************************************************** + * 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 { styled } from '@mui/material/styles'; + +export const DrawerHeader = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + padding: theme.spacing(0, 1), + // necessary for content to be below app bar + ...theme.mixins.toolbar, + justifyContent: 'flex-end', +})); diff --git a/webui/src/components/sidepanel/sidepanel.tsx b/webui/src/components/sidepanel/sidepanel.tsx index cacf330cc..a10c8374a 100644 --- a/webui/src/components/sidepanel/sidepanel.tsx +++ b/webui/src/components/sidepanel/sidepanel.tsx @@ -10,17 +10,8 @@ import { FunctionComponent, PropsWithChildren } from 'react'; import { Divider, Drawer, IconButton, List } from '@mui/material'; -import { styled } from '@mui/material/styles'; -import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; - -export const DrawerHeader = styled('div')(({ theme }) => ({ - display: 'flex', - alignItems: 'center', - padding: theme.spacing(0, 1), - // necessary for content to be below app bar - ...theme.mixins.toolbar, - justifyContent: 'flex-end', -})); +import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; +import { DrawerHeader } from './drawer-header'; export const Sidepanel: FunctionComponent> = props => { const width = props.width; diff --git a/webui/src/default/default-app.tsx b/webui/src/default/default-app.tsx index 821b02d49..7dc04478e 100644 --- a/webui/src/default/default-app.tsx +++ b/webui/src/default/default-app.tsx @@ -48,7 +48,7 @@ async function getServerVersion(): Promise { } } -const App = () => { +export const App = () => { const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); const theme = useMemo( () => createDefaultTheme(prefersDarkMode ? 'dark' : 'light'), diff --git a/webui/src/default/menu-content.tsx b/webui/src/default/menu-content.tsx index a5ec8b00f..aacb08b80 100644 --- a/webui/src/default/menu-content.tsx +++ b/webui/src/default/menu-content.tsx @@ -19,13 +19,13 @@ import InfoIcon from '@mui/icons-material/Info'; import PublishIcon from '@mui/icons-material/Publish'; import AccountBoxIcon from '@mui/icons-material/AccountBox'; import { UserAvatar } from '../pages/user/avatar'; -import { UserSettingsRoutes } from '../pages/user/user-settings'; +import { UserSettingsRoutes } from '../pages/user/user-settings-routes'; import { styled, Theme } from '@mui/material/styles'; import { MainContext } from '../context'; import SettingsIcon from '@mui/icons-material/Settings'; import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; import LogoutIcon from '@mui/icons-material/Logout'; -import { AdminDashboardRoutes } from '../pages/admin-dashboard/admin-dashboard'; +import { AdminDashboardRoutes } from '../pages/admin-dashboard/admin-routes'; import { LogoutForm } from '../pages/user/logout'; import { LoginComponent } from './login'; diff --git a/webui/src/default/page-settings.tsx b/webui/src/default/page-settings.tsx index d83acbece..8bcc0a161 100644 --- a/webui/src/default/page-settings.tsx +++ b/webui/src/default/page-settings.tsx @@ -16,7 +16,7 @@ import { Link as RouteLink, Route, useParams } from 'react-router-dom'; import GitHubIcon from '@mui/icons-material/GitHub'; import { Extension, NamespaceDetails } from '../extension-registry-types'; import { PageSettings } from '../page-settings'; -import { ExtensionListRoutes } from '../pages/extension-list/extension-list-container'; +import { ExtensionListRoutes } from '../pages/extension-list/extension-list-routes'; import { DefaultMenuContent, MobileMenuContent } from './menu-content'; import OpenVSXLogo from './openvsx-registry-logo'; import About from './about'; diff --git a/webui/src/main.tsx b/webui/src/main.tsx index 96cca9f2a..ddf493074 100644 --- a/webui/src/main.tsx +++ b/webui/src/main.tsx @@ -11,7 +11,8 @@ import { FunctionComponent, ReactNode, useEffect, useState, useRef } from 'react'; import { CssBaseline } from '@mui/material'; import { Route, Routes } from 'react-router-dom'; -import { AdminDashboard, AdminDashboardRoutes } from './pages/admin-dashboard/admin-dashboard'; +import { AdminDashboard } from './pages/admin-dashboard/admin-dashboard'; +import { AdminDashboardRoutes } from './pages/admin-dashboard/admin-routes'; import { ErrorDialog } from './components/error-dialog'; import { handleError } from './utils'; import { ExtensionRegistryService } from './extension-registry-service'; diff --git a/webui/src/other-pages.tsx b/webui/src/other-pages.tsx index a08c7a9ae..e1f382669 100644 --- a/webui/src/other-pages.tsx +++ b/webui/src/other-pages.tsx @@ -1,3 +1,16 @@ +/****************************************************************************** + * 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, useContext, useEffect, useState } from 'react'; import { Routes, Route } from 'react-router-dom'; import { AppBar, Box, Toolbar } from '@mui/material'; @@ -5,10 +18,14 @@ import { styled, Theme } from '@mui/material/styles'; import { Banner } from './components/banner'; import { MainContext } from './context'; import { HeaderMenu } from './header-menu'; -import { ExtensionListContainer, ExtensionListRoutes } from './pages/extension-list/extension-list-container'; -import { UserSettings, UserSettingsRoutes } from './pages/user/user-settings'; -import { NamespaceDetail, NamespaceDetailRoutes } from './pages/namespace-detail/namespace-detail'; -import { ExtensionDetail, ExtensionDetailRoutes } from './pages/extension-detail/extension-detail'; +import { ExtensionListContainer } from './pages/extension-list/extension-list-container'; +import { ExtensionListRoutes } from "./pages/extension-list/extension-list-routes"; +import { UserSettings } from './pages/user/user-settings'; +import { UserSettingsRoutes } from './pages/user/user-settings-routes'; +import { NamespaceDetail } from './pages/namespace-detail/namespace-detail'; +import { NamespaceDetailRoutes } from './pages/namespace-detail/namespace-detail-routes'; +import { ExtensionDetail } from './pages/extension-detail/extension-detail'; +import { ExtensionDetailRoutes } from './pages/extension-detail/extension-detail-routes'; import { getCookieValueByKey, setCookie } from './utils'; import { UserData } from './extension-registry-types'; import { NotFound } from './not-found'; diff --git a/webui/src/pages/admin-dashboard/admin-dashboard.tsx b/webui/src/pages/admin-dashboard/admin-dashboard.tsx index 8a59bc8ae..8fcbc67e5 100644 --- a/webui/src/pages/admin-dashboard/admin-dashboard.tsx +++ b/webui/src/pages/admin-dashboard/admin-dashboard.tsx @@ -29,7 +29,7 @@ import BarChartIcon from '@mui/icons-material/BarChart'; import ExtensionSharpIcon from '@mui/icons-material/ExtensionSharp'; import HighlightOffIcon from '@mui/icons-material/HighlightOff'; import HistoryIcon from '@mui/icons-material/History'; -import MenuIcon from "@mui/icons-material/Menu"; +import MenuIcon from '@mui/icons-material/Menu'; import PeopleIcon from '@mui/icons-material/People'; import PersonIcon from '@mui/icons-material/Person'; import SecurityIcon from '@mui/icons-material/Security'; @@ -37,32 +37,20 @@ import SpeedIcon from '@mui/icons-material/Speed'; import StarIcon from '@mui/icons-material/Star'; import { CustomerDetails } from './customers/customer-details'; import { Customers } from './customers/customers'; -import { DrawerHeader, Sidepanel } from "../../components/sidepanel/sidepanel"; +import { DrawerHeader } from '../../components/sidepanel/drawer-header'; +import { Sidepanel } from "../../components/sidepanel/sidepanel"; import { ExtensionAdmin } from './extension-admin'; import { LoginComponent } from "../../default/login"; import { Logs } from './logs/logs'; import { MainContext } from '../../context'; import { NamespaceAdmin } from './namespace-admin'; -import { NavigationItem } from "../../components/sidepanel/navigation-item"; +import { NavigationItem } from '../../components/sidepanel/navigation-item'; import { PublisherAdmin } from './publisher-admin'; import { ScanAdmin } from './scan-admin'; import { Tiers } from './tiers/tiers'; import { UsageStatsView } from './usage-stats/usage-stats'; import { Welcome } from './welcome'; -import { createRoute } from '../../utils'; - -export namespace AdminDashboardRoutes { - export const ROOT = 'admin-dashboard'; - export const MAIN = createRoute([ROOT]); - export const NAMESPACE_ADMIN = createRoute([ROOT, 'namespaces']); - export const EXTENSION_ADMIN = createRoute([ROOT, 'extensions']); - export const PUBLISHER_ADMIN = createRoute([ROOT, 'publisher']); - export const SCANS_ADMIN = createRoute([ROOT, 'scans']); - 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']); -} +import { AdminDashboardRoutes } from "./admin-routes"; const Message: FunctionComponent<{message: string}> = ({ message }) => { return ( { diff --git a/webui/src/pages/admin-dashboard/publisher-admin.tsx b/webui/src/pages/admin-dashboard/publisher-admin.tsx index 323f4fb22..863699b6c 100644 --- a/webui/src/pages/admin-dashboard/publisher-admin.tsx +++ b/webui/src/pages/admin-dashboard/publisher-admin.tsx @@ -16,7 +16,7 @@ import { MainContext } from '../../context'; import { StyledInput } from './namespace-input'; import { SearchListContainer } from './search-list-container'; import { PublisherDetails } from './publisher-details'; -import { AdminDashboardRoutes } from './admin-dashboard'; +import { AdminDashboardRoutes } from './admin-routes'; export const UpdateContext = createContext({ handleUpdate: () => { } }); export const PublisherAdmin: FunctionComponent = () => { diff --git a/webui/src/pages/admin-dashboard/usage-stats/usage-stats.tsx b/webui/src/pages/admin-dashboard/usage-stats/usage-stats.tsx index adbbdb47e..a023da090 100644 --- a/webui/src/pages/admin-dashboard/usage-stats/usage-stats.tsx +++ b/webui/src/pages/admin-dashboard/usage-stats/usage-stats.tsx @@ -17,7 +17,7 @@ import { useParams, useNavigate } from "react-router-dom"; import { MainContext } from "../../../context"; import type { Customer } from "../../../extension-registry-types"; import { handleError } from "../../../utils"; -import { AdminDashboardRoutes } from "../admin-dashboard"; +import { AdminDashboardRoutes } from "../admin-routes"; import { SearchListContainer } from "../search-list-container"; import { CustomerSearch } from "./usage-stats-search"; import { UsageStatsChart } from "./usage-stats-chart"; diff --git a/webui/src/pages/admin-dashboard/welcome.tsx b/webui/src/pages/admin-dashboard/welcome.tsx index b2c7c1bfb..96397ff03 100644 --- a/webui/src/pages/admin-dashboard/welcome.tsx +++ b/webui/src/pages/admin-dashboard/welcome.tsx @@ -12,7 +12,7 @@ import { FunctionComponent } from 'react'; import { Typography, Grid, Paper } from '@mui/material'; import { styled, Theme } from '@mui/material/styles'; import { Link } from 'react-router-dom'; -import { AdminDashboardRoutes } from './admin-dashboard'; +import { AdminDashboardRoutes } from './admin-routes'; export const Welcome: FunctionComponent = props => { return diff --git a/webui/src/pages/extension-detail/extension-detail-overview.tsx b/webui/src/pages/extension-detail/extension-detail-overview.tsx index 2adfbe570..eb63ec1a0 100644 --- a/webui/src/pages/extension-detail/extension-detail-overview.tsx +++ b/webui/src/pages/extension-detail/extension-detail-overview.tsx @@ -21,8 +21,8 @@ import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; import { SanitizedMarkdown } from '../../components/sanitized-markdown'; import { Timestamp } from '../../components/timestamp'; import { Extension, ExtensionReference, VERSION_ALIASES } from '../../extension-registry-types'; -import { ExtensionListRoutes } from '../extension-list/extension-list-container'; -import { ExtensionDetailRoutes } from './extension-detail'; +import { ExtensionListRoutes } from '../extension-list/extension-list-routes'; +import { ExtensionDetailRoutes } from './extension-detail-routes'; import { ExtensionDetailDownloadsMenu } from './extension-detail-downloads-menu'; export const ExtensionDetailOverview: FunctionComponent = props => { diff --git a/webui/src/pages/extension-detail/extension-detail-routes.ts b/webui/src/pages/extension-detail/extension-detail-routes.ts new file mode 100644 index 000000000..d30301a0f --- /dev/null +++ b/webui/src/pages/extension-detail/extension-detail-routes.ts @@ -0,0 +1,35 @@ +/****************************************************************************** + * 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 { createRoute, getTargetPlatforms } from '../../utils'; + +export namespace ExtensionDetailRoutes { + export namespace Parameters { + export const NAMESPACE = ':namespace'; + export const NAME = ':name'; + export const TARGET = `:target(${getTargetPlatforms().join('|')})`; + export const VERSION = ':version?'; + } + + export const ROOT = 'extension'; + export const MAIN = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, Parameters.VERSION]); + export const MAIN_TARGET = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, Parameters.TARGET, Parameters.VERSION]); + export const LATEST = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME]); + export const LATEST_TARGET = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, Parameters.TARGET]); + export const PRE_RELEASE = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, 'pre-release']); + export const PRE_RELEASE_TARGET = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, Parameters.TARGET, 'pre-release']); + export const REVIEWS = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, 'reviews']); + export const REVIEWS_TARGET = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, Parameters.TARGET, 'reviews']); + export const CHANGES = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, 'changes']); + export const CHANGES_TARGET = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, Parameters.TARGET, 'changes']); +} diff --git a/webui/src/pages/extension-detail/extension-detail.tsx b/webui/src/pages/extension-detail/extension-detail.tsx index 5ce4f4bb1..55f973185 100644 --- a/webui/src/pages/extension-detail/extension-detail.tsx +++ b/webui/src/pages/extension-detail/extension-detail.tsx @@ -18,39 +18,19 @@ import SaveAltIcon from '@mui/icons-material/SaveAlt'; import VerifiedUserIcon from '@mui/icons-material/VerifiedUser'; import WarningIcon from '@mui/icons-material/Warning'; import { MainContext } from '../../context'; -import { createRoute, getTargetPlatforms } from '../../utils'; +import { createRoute } from '../../utils'; import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; import { HoverPopover } from '../../components/hover-popover'; import { Extension, UserData, isError } from '../../extension-registry-types'; import { TextDivider } from '../../components/text-divider'; import { ExtensionRatingStars } from './extension-rating-stars'; -import { NamespaceDetailRoutes } from '../namespace-detail/namespace-detail'; +import { NamespaceDetailRoutes } from '../namespace-detail/namespace-detail-routes'; import { ExtensionDetailOverview } from './extension-detail-overview'; import { ExtensionDetailChanges } from './extension-detail-changes'; import { ExtensionDetailReviews } from './extension-detail-reviews'; +import { ExtensionDetailRoutes } from './extension-detail-routes'; import styled from '@mui/material/styles/styled'; -export namespace ExtensionDetailRoutes { - export namespace Parameters { - export const NAMESPACE = ':namespace'; - export const NAME = ':name'; - export const TARGET = `:target(${getTargetPlatforms().join('|')})`; - export const VERSION = ':version?'; - } - - export const ROOT = 'extension'; - export const MAIN = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, Parameters.VERSION]); - export const MAIN_TARGET = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, Parameters.TARGET, Parameters.VERSION]); - export const LATEST = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME]); - export const LATEST_TARGET = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, Parameters.TARGET]); - export const PRE_RELEASE = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, 'pre-release']); - export const PRE_RELEASE_TARGET = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, Parameters.TARGET, 'pre-release']); - export const REVIEWS = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, 'reviews']); - export const REVIEWS_TARGET = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, Parameters.TARGET, 'reviews']); - export const CHANGES = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, 'changes']); - export const CHANGES_TARGET = createRoute([ROOT, Parameters.NAMESPACE, Parameters.NAME, Parameters.TARGET, 'changes']); -} - const alignVertically = { display: 'flex', alignItems: 'center' diff --git a/webui/src/pages/extension-list/extension-list-container.tsx b/webui/src/pages/extension-list/extension-list-container.tsx index 14da40846..d557255d5 100644 --- a/webui/src/pages/extension-list/extension-list-container.tsx +++ b/webui/src/pages/extension-list/extension-list-container.tsx @@ -11,15 +11,11 @@ import { FunctionComponent, useEffect, useState } from 'react'; import { Box } from '@mui/material'; import { useLocation } from 'react-router-dom'; -import { createRoute, addQuery } from '../../utils'; +import { addQuery } from '../../utils'; import { ExtensionCategory, SortOrder, SortBy } from '../../extension-registry-types'; import { ExtensionList } from './extension-list'; import { ExtensionListHeader } from './extension-list-header'; -export namespace ExtensionListRoutes { - export const MAIN = createRoute([]); -} - export const ExtensionListContainer: FunctionComponent = () => { const [searchQuery, setSearchQuery] = useState(''); diff --git a/webui/src/pages/extension-list/extension-list-item.tsx b/webui/src/pages/extension-list/extension-list-item.tsx index 9299b8f9d..7305e8da6 100644 --- a/webui/src/pages/extension-list/extension-list-item.tsx +++ b/webui/src/pages/extension-list/extension-list-item.tsx @@ -13,7 +13,7 @@ import { Link as RouteLink } from 'react-router-dom'; import { Paper, Typography, Box, Grid, Fade } from '@mui/material'; import SaveAltIcon from '@mui/icons-material/SaveAlt'; import { MainContext } from '../../context'; -import { ExtensionDetailRoutes } from '../extension-detail/extension-detail'; +import { ExtensionDetailRoutes } from '../extension-detail/extension-detail-routes'; import { SearchEntry } from '../../extension-registry-types'; import { ExtensionRatingStars } from '../extension-detail/extension-rating-stars'; import { createRoute } from '../../utils'; diff --git a/webui/src/pages/extension-list/extension-list-routes.ts b/webui/src/pages/extension-list/extension-list-routes.ts new file mode 100644 index 000000000..3ff30749f --- /dev/null +++ b/webui/src/pages/extension-list/extension-list-routes.ts @@ -0,0 +1,18 @@ +/****************************************************************************** + * 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 { createRoute } from '../../utils'; + +export namespace ExtensionListRoutes { + export const MAIN = createRoute([]); +} diff --git a/webui/src/pages/namespace-detail/namespace-detail-routes.ts b/webui/src/pages/namespace-detail/namespace-detail-routes.ts new file mode 100644 index 000000000..3815416e3 --- /dev/null +++ b/webui/src/pages/namespace-detail/namespace-detail-routes.ts @@ -0,0 +1,23 @@ +/****************************************************************************** + * 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 { createRoute } from '../../utils'; + +export namespace NamespaceDetailRoutes { + export namespace Parameters { + export const NAME = ':name'; + } + + export const ROOT = 'namespace'; + export const MAIN = createRoute([ROOT, Parameters.NAME]); +} diff --git a/webui/src/pages/namespace-detail/namespace-detail.tsx b/webui/src/pages/namespace-detail/namespace-detail.tsx index 4a6ce85ba..b13c34073 100644 --- a/webui/src/pages/namespace-detail/namespace-detail.tsx +++ b/webui/src/pages/namespace-detail/namespace-detail.tsx @@ -16,19 +16,9 @@ import TwitterIcon from '@mui/icons-material/Twitter'; import { useParams } from 'react-router-dom'; import { ExtensionListItem } from '../extension-list/extension-list-item'; import { MainContext } from '../../context'; -import { createRoute } from '../../utils'; import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; import { NamespaceDetails, isError, UrlString } from '../../extension-registry-types'; -export namespace NamespaceDetailRoutes { - export namespace Parameters { - export const NAME = ':name'; - } - - export const ROOT = 'namespace'; - export const MAIN = createRoute([ROOT, Parameters.NAME]); -} - export const NamespaceDetail: FunctionComponent = () => { const [loading, setLoading] = useState(true); const [truncateReadMore, setTruncateReadMore] = useState(true); diff --git a/webui/src/pages/user/avatar.tsx b/webui/src/pages/user/avatar.tsx index b2ceb5b6c..629e223c8 100644 --- a/webui/src/pages/user/avatar.tsx +++ b/webui/src/pages/user/avatar.tsx @@ -11,8 +11,8 @@ import { FunctionComponent, useContext, useRef, useState } from 'react'; import { Avatar, Menu, Typography, MenuItem, Link, Divider, IconButton } from '@mui/material'; import { Link as RouteLink } from 'react-router-dom'; -import { UserSettingsRoutes } from './user-settings'; -import { AdminDashboardRoutes } from '../admin-dashboard/admin-dashboard'; +import { UserSettingsRoutes } from './user-settings-routes'; +import { AdminDashboardRoutes } from '../admin-dashboard/admin-routes'; import { MainContext } from '../../context'; import { LogoutForm } from './logout'; diff --git a/webui/src/pages/user/user-namespace-extension-list-item.tsx b/webui/src/pages/user/user-namespace-extension-list-item.tsx index 1e9b1c34c..889f1c5e0 100644 --- a/webui/src/pages/user/user-namespace-extension-list-item.tsx +++ b/webui/src/pages/user/user-namespace-extension-list-item.tsx @@ -16,9 +16,9 @@ import { Link as RouteLink, useNavigate } from 'react-router-dom'; import { MainContext } from '../../context'; import { createRoute } from '../../utils'; import { Timestamp } from '../../components/timestamp'; -import { ExtensionDetailRoutes } from '../extension-detail/extension-detail'; +import { ExtensionDetailRoutes } from '../extension-detail/extension-detail-routes'; import DeleteIcon from '@mui/icons-material/Delete'; -import { UserSettingsRoutes } from './user-settings'; +import { UserSettingsRoutes } from './user-settings-routes'; const getOpacity = (extension: Extension) => { if (extension.deprecated) { diff --git a/webui/src/pages/user/user-setting-tabs.tsx b/webui/src/pages/user/user-setting-tabs.tsx index 7bedff831..b5fbfb645 100644 --- a/webui/src/pages/user/user-setting-tabs.tsx +++ b/webui/src/pages/user/user-setting-tabs.tsx @@ -12,7 +12,7 @@ import { ChangeEvent, ReactElement } from 'react'; import { Tabs, Tab, useTheme, useMediaQuery } from '@mui/material'; import { useNavigate, useParams } from 'react-router-dom'; import { createRoute } from '../../utils'; -import { UserSettingsRoutes } from './user-settings'; +import { UserSettingsRoutes } from './user-settings-routes'; export const UserSettingTabs = (): ReactElement => { diff --git a/webui/src/pages/user/user-settings-delete-extension.tsx b/webui/src/pages/user/user-settings-delete-extension.tsx index b3f122d1c..461abb11a 100644 --- a/webui/src/pages/user/user-settings-delete-extension.tsx +++ b/webui/src/pages/user/user-settings-delete-extension.tsx @@ -15,7 +15,7 @@ import { isError, Extension, TargetPlatformVersion } from '../../extension-regis import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; import { ExtensionVersionContainer } from '../admin-dashboard/extension-version-container'; import { useNavigate } from 'react-router'; -import { UserSettingsRoutes } from './user-settings'; +import { UserSettingsRoutes } from './user-settings-routes'; export const UserSettingsDeleteExtension: FunctionComponent = props => { const navigate = useNavigate(); diff --git a/webui/src/pages/user/user-settings-namespace-detail.tsx b/webui/src/pages/user/user-settings-namespace-detail.tsx index cbc60015e..3e08fa181 100644 --- a/webui/src/pages/user/user-settings-namespace-detail.tsx +++ b/webui/src/pages/user/user-settings-namespace-detail.tsx @@ -14,7 +14,7 @@ import { Box, Button, Link, Paper, Grid, Typography } from '@mui/material'; import { styled, Theme } from '@mui/material/styles'; import WarningIcon from '@mui/icons-material/Warning'; import { UserNamespaceExtensionListContainer } from './user-namespace-extension-list'; -import { AdminDashboardRoutes } from '../admin-dashboard/admin-dashboard'; +import { AdminDashboardRoutes } from '../admin-dashboard/admin-routes'; import { Namespace, UserData } from '../../extension-registry-types'; import { NamespaceChangeDialog } from '../admin-dashboard/namespace-change-dialog'; import { UserNamespaceMemberList } from './user-namespace-member-list'; diff --git a/webui/src/pages/user/user-settings-routes.ts b/webui/src/pages/user/user-settings-routes.ts new file mode 100644 index 000000000..04fe581ef --- /dev/null +++ b/webui/src/pages/user/user-settings-routes.ts @@ -0,0 +1,24 @@ +/****************************************************************************** + * 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 { createRoute } from '../../utils'; + +export namespace UserSettingsRoutes { + export const ROOT = createRoute(['user-settings']); + export const MAIN = createRoute([ROOT, ':tab']); + export const PROFILE = createRoute([ROOT, 'profile']); + export const TOKENS = createRoute([ROOT, 'tokens']); + export const NAMESPACES = createRoute([ROOT, 'namespaces']); + export const EXTENSIONS = createRoute([ROOT, 'extensions']); + export const DELETE_EXTENSION = createRoute([ROOT, 'extensions', ':namespace', ':extension', 'delete']); +} diff --git a/webui/src/pages/user/user-settings-tokens.tsx b/webui/src/pages/user/user-settings-tokens.tsx index 88a001362..8615c332b 100644 --- a/webui/src/pages/user/user-settings-tokens.tsx +++ b/webui/src/pages/user/user-settings-tokens.tsx @@ -16,7 +16,7 @@ import { Timestamp } from '../../components/timestamp'; import { PersonalAccessToken } from '../../extension-registry-types'; import { MainContext } from '../../context'; import { GenerateTokenDialog } from './generate-token-dialog'; -import { UserSettingsRoutes } from './user-settings'; +import { UserSettingsRoutes } from './user-settings-routes'; import styled from '@mui/material/styles/styled'; const link = ({ theme }: { theme: Theme }) => ({ @@ -216,10 +216,3 @@ export const UserSettingsTokens: FunctionComponent = () => { ; }; - -export namespace UserSettingsTokens { - export interface State { - tokens: PersonalAccessToken[]; - loading: boolean; - } -} \ No newline at end of file diff --git a/webui/src/pages/user/user-settings.tsx b/webui/src/pages/user/user-settings.tsx index c06982002..10932a677 100644 --- a/webui/src/pages/user/user-settings.tsx +++ b/webui/src/pages/user/user-settings.tsx @@ -12,7 +12,6 @@ import { FunctionComponent, ReactNode, useContext } from 'react'; import { Helmet } from 'react-helmet-async'; import { Grid, Container, Box, Typography, Link } from '@mui/material'; import { useParams } from 'react-router-dom'; -import { createRoute } from '../../utils'; import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; import { UserSettingTabs } from './user-setting-tabs'; import { UserSettingsTokens } from './user-settings-tokens'; @@ -24,16 +23,6 @@ import { UserData } from '../../extension-registry-types'; import { LoginComponent } from '../../default/login'; import { UserSettingsDeleteExtension } from './user-settings-delete-extension'; -export namespace UserSettingsRoutes { - export const ROOT = createRoute(['user-settings']); - export const MAIN = createRoute([ROOT, ':tab']); - export const PROFILE = createRoute([ROOT, 'profile']); - export const TOKENS = createRoute([ROOT, 'tokens']); - export const NAMESPACES = createRoute([ROOT, 'namespaces']); - export const EXTENSIONS = createRoute([ROOT, 'extensions']); - export const DELETE_EXTENSION = createRoute([ROOT, 'extensions', ':namespace', ':extension', 'delete']); -} - export const UserSettings: FunctionComponent = props => { const { pageSettings, user, loginProviders } = useContext(MainContext); diff --git a/webui/yarn.lock b/webui/yarn.lock index fa538051d..20b298b6f 100644 --- a/webui/yarn.lock +++ b/webui/yarn.lock @@ -3831,6 +3831,15 @@ __metadata: languageName: node linkType: hard +"eslint-plugin-react-refresh@npm:^0.5.2": + version: 0.5.2 + resolution: "eslint-plugin-react-refresh@npm:0.5.2" + peerDependencies: + eslint: ^9 || ^10 + checksum: 10/155a2e66d74866352f023b2a2d9b0daf1fb3033638851321caafa48fe9c9984830acc089d3832348ff2df2848791db96d17bd43639860b045b9f7ee4e5f86dcc + languageName: node + linkType: hard + "eslint-plugin-react@npm:^7.37.0": version: 7.37.5 resolution: "eslint-plugin-react@npm:7.37.5" @@ -5875,6 +5884,7 @@ __metadata: dompurify: "npm:^3.0.4" eslint: "npm:^9.39.0" eslint-plugin-react: "npm:^7.37.0" + eslint-plugin-react-refresh: "npm:^0.5.2" express: "npm:^4.21.0" express-rate-limit: "npm:^7.4.0" fetch-retry: "npm:^5.0.6" From 8d8bab47cd28cf53408c9ea8bb2755a2276a58c1 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Fri, 20 Mar 2026 14:38:12 +0100 Subject: [PATCH 10/18] suppress remaining eslint errors --- webui/src/context/scan-admin/scan-context.tsx | 1 + webui/src/default/menu-content.tsx | 4 ++ .../components/data-grid-filter-operators.tsx | 32 +------------ .../components/data-grid-filter.tsx | 45 +++++++++++++++++++ .../pages/admin-dashboard/components/index.ts | 2 +- .../pages/admin-dashboard/publisher-admin.tsx | 2 + .../user/user-settings-namespace-detail.tsx | 2 + 7 files changed, 56 insertions(+), 32 deletions(-) create mode 100644 webui/src/pages/admin-dashboard/components/data-grid-filter.tsx diff --git a/webui/src/context/scan-admin/scan-context.tsx b/webui/src/context/scan-admin/scan-context.tsx index 3c3dd0e62..f205b8dc1 100644 --- a/webui/src/context/scan-admin/scan-context.tsx +++ b/webui/src/context/scan-admin/scan-context.tsx @@ -114,6 +114,7 @@ export const ScanProvider: FC = ({ children, service, handleE // Custom Hook // ============================================================================ +// eslint-disable-next-line react-refresh/only-export-components export const useScanContext = (): ScanContextValue => { const context = useContext(ScanContext); if (!context) { diff --git a/webui/src/default/menu-content.tsx b/webui/src/default/menu-content.tsx index aacb08b80..eabb569ec 100644 --- a/webui/src/default/menu-content.tsx +++ b/webui/src/default/menu-content.tsx @@ -30,6 +30,7 @@ import { LogoutForm } from '../pages/user/logout'; import { LoginComponent } from './login'; //-------------------- Mobile View --------------------// +// eslint-disable-next-line react-refresh/only-export-components export const itemIcon = { mr: 1, width: '16px', @@ -161,6 +162,7 @@ export const MobileMenuContent: FunctionComponent = () => { //-------------------- Default View --------------------// +// eslint-disable-next-line react-refresh/only-export-components export const headerItem = ({ theme }: { theme: Theme }) => ({ margin: theme.spacing(2.5), color: theme.palette.text.primary, @@ -175,7 +177,9 @@ export const headerItem = ({ theme }: { theme: Theme }) => ({ } }); +// eslint-disable-next-line react-refresh/only-export-components export const MenuLink = styled(Link)(headerItem); +// eslint-disable-next-line react-refresh/only-export-components export const MenuRouteLink = styled(RouteLink)(headerItem); export const DefaultMenuContent: FunctionComponent = () => { diff --git a/webui/src/pages/admin-dashboard/components/data-grid-filter-operators.tsx b/webui/src/pages/admin-dashboard/components/data-grid-filter-operators.tsx index 4dd77bf84..c09e37ca4 100644 --- a/webui/src/pages/admin-dashboard/components/data-grid-filter-operators.tsx +++ b/webui/src/pages/admin-dashboard/components/data-grid-filter-operators.tsx @@ -11,39 +11,9 @@ * SPDX-License-Identifier: EPL-2.0 *****************************************************************************/ -import type { SyntheticEvent } from "react"; -import { FC } from 'react'; -import { Autocomplete, TextField } from '@mui/material'; +import { MultiSelectFilterInput } from "./data-grid-filter"; import { GridFilterOperator, GridFilterInputValueProps } from '@mui/x-data-grid'; -/** - * Custom multi-select filter input component for DataGrid columns. - * Renders an Autocomplete with multiple selection support. - */ -export const MultiSelectFilterInput: FC = ({ - item, - applyValue, - options -}) => { - const handleChange = (_event: SyntheticEvent, newValue: string[]) => { - applyValue({ ...item, value: newValue }); - }; - - return ( - ( - - )} - sx={{ minWidth: 150, mt: 'auto' }} - /> - ); -}; - /** * Creates filter operators for single-value columns with multi-select capability. * Includes "is any of" and "is none of" operators. diff --git a/webui/src/pages/admin-dashboard/components/data-grid-filter.tsx b/webui/src/pages/admin-dashboard/components/data-grid-filter.tsx new file mode 100644 index 000000000..ac38980b4 --- /dev/null +++ b/webui/src/pages/admin-dashboard/components/data-grid-filter.tsx @@ -0,0 +1,45 @@ +/****************************************************************************** + * 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 type { SyntheticEvent } from "react"; +import { FC } from 'react'; +import { Autocomplete, TextField } from '@mui/material'; +import { GridFilterInputValueProps } from '@mui/x-data-grid'; + +/** + * Custom multi-select filter input component for DataGrid columns. + * Renders an Autocomplete with multiple selection support. + */ +export const MultiSelectFilterInput: FC = ({ + item, + applyValue, + options +}) => { + const handleChange = (_event: SyntheticEvent, newValue: string[]) => { + applyValue({ ...item, value: newValue }); + }; + + return ( + ( + + )} + sx={{ minWidth: 150, mt: 'auto' }} + /> + ); +}; diff --git a/webui/src/pages/admin-dashboard/components/index.ts b/webui/src/pages/admin-dashboard/components/index.ts index f1d17d829..527f00a8a 100644 --- a/webui/src/pages/admin-dashboard/components/index.ts +++ b/webui/src/pages/admin-dashboard/components/index.ts @@ -11,8 +11,8 @@ * SPDX-License-Identifier: EPL-2.0 *****************************************************************************/ +export { MultiSelectFilterInput } from './data-grid-filter'; export { - MultiSelectFilterInput, createMultiSelectFilterOperators, createArrayContainsFilterOperators } from './data-grid-filter-operators'; diff --git a/webui/src/pages/admin-dashboard/publisher-admin.tsx b/webui/src/pages/admin-dashboard/publisher-admin.tsx index 863699b6c..5579905a6 100644 --- a/webui/src/pages/admin-dashboard/publisher-admin.tsx +++ b/webui/src/pages/admin-dashboard/publisher-admin.tsx @@ -18,7 +18,9 @@ import { SearchListContainer } from './search-list-container'; import { PublisherDetails } from './publisher-details'; import { AdminDashboardRoutes } from './admin-routes'; +// eslint-disable-next-line react-refresh/only-export-components export const UpdateContext = createContext({ handleUpdate: () => { } }); + export const PublisherAdmin: FunctionComponent = () => { const { publisher: publisherParam } = useParams<{ publisher: string }>(); const navigate = useNavigate(); diff --git a/webui/src/pages/user/user-settings-namespace-detail.tsx b/webui/src/pages/user/user-settings-namespace-detail.tsx index 3e08fa181..bbefce89c 100644 --- a/webui/src/pages/user/user-settings-namespace-detail.tsx +++ b/webui/src/pages/user/user-settings-namespace-detail.tsx @@ -23,6 +23,8 @@ import { UserNamespaceDetails } from './user-namespace-details'; export interface NamespaceDetailConfig { defaultMemberRole?: 'contributor' | 'owner'; } + +// eslint-disable-next-line react-refresh/only-export-components export const NamespaceDetailConfigContext = createContext({}); const NamespaceDetailContainer = styled(Grid)(({ theme }: { theme: Theme }) => ({ From d2425d7c4c2b24a5b03e4df7ff423949328ee9b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez=20Hidalgo?= <31970428+gnugomez@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:08:26 +0100 Subject: [PATCH 11/18] feat: adding rate limiting tab to user settings (#1707) --- .../customer/general-details.tsx | 106 +++++++++ .../rate-limiting/customer/index.ts | 21 ++ .../rate-limiting/customer/members.tsx | 68 ++++++ .../rate-limiting/customer/usage-stats.tsx | 50 ++++ .../usage-stats/usage-stats-chart.tsx | 20 +- .../usage-stats/usage-stats-utils.ts | 0 .../usage-stats/use-usage-stats.ts | 70 ++++++ webui/src/extension-registry-service.ts | 164 +++++++++---- .../customers/customer-details.tsx | 219 +++--------------- .../usage-stats/usage-stats.tsx | 6 +- .../usage-stats/use-usage-stats.ts | 4 +- webui/src/pages/user/user-setting-tabs.tsx | 1 + .../user/user-settings-customer-detail.tsx | 39 ++++ .../pages/user/user-settings-customers.tsx | 121 ++++++++++ webui/src/pages/user/user-settings-routes.ts | 1 + webui/src/pages/user/user-settings.tsx | 3 + 16 files changed, 647 insertions(+), 246 deletions(-) create mode 100644 webui/src/components/rate-limiting/customer/general-details.tsx create mode 100644 webui/src/components/rate-limiting/customer/index.ts create mode 100644 webui/src/components/rate-limiting/customer/members.tsx create mode 100644 webui/src/components/rate-limiting/customer/usage-stats.tsx rename webui/src/{pages/admin-dashboard => components/rate-limiting}/usage-stats/usage-stats-chart.tsx (92%) rename webui/src/{pages/admin-dashboard => components/rate-limiting}/usage-stats/usage-stats-utils.ts (100%) create mode 100644 webui/src/components/rate-limiting/usage-stats/use-usage-stats.ts create mode 100644 webui/src/pages/user/user-settings-customer-detail.tsx create mode 100644 webui/src/pages/user/user-settings-customers.tsx diff --git a/webui/src/components/rate-limiting/customer/general-details.tsx b/webui/src/components/rate-limiting/customer/general-details.tsx new file mode 100644 index 000000000..7baa661a1 --- /dev/null +++ b/webui/src/components/rate-limiting/customer/general-details.tsx @@ -0,0 +1,106 @@ +/****************************************************************************** + * 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 { FC, type ReactNode } from 'react'; +import { + Box, + Typography, + Paper, + type PaperProps, + Chip, + Stack, + Divider, + Grid, +} from '@mui/material'; +import type { Customer } from '../../../extension-registry-types'; + +const sectionPaperProps: PaperProps = { elevation: 1, sx: { p: 3, mb: 3 } }; + +export interface GeneralDetailsProps { + customer: Customer; + headerAction?: ReactNode; +} + +export const GeneralDetails: FC = ({ customer, headerAction }) => { + const tier = customer.tier; + return ( + + + General Information + {headerAction && {headerAction}} + + + + + Name + {customer.name} + + + State + + + + + {tier ? ( + <> + + Tier + + + + + + Tier Type + {tier.tierType} + + + Capacity + {tier.capacity} requests / {tier.duration}s + + + Refill Strategy + {tier.refillStrategy} + + {tier.description && ( + + Tier Description + {tier.description} + + )} + + ) : ( + + Tier + No tier assigned + + )} + + CIDR Blocks + {customer.cidrBlocks.length > 0 ? ( + + {customer.cidrBlocks.map((cidr) => ( + + ))} + + ) : ( + None configured + )} + + + + ); +}; diff --git a/webui/src/components/rate-limiting/customer/index.ts b/webui/src/components/rate-limiting/customer/index.ts new file mode 100644 index 000000000..6a8709950 --- /dev/null +++ b/webui/src/components/rate-limiting/customer/index.ts @@ -0,0 +1,21 @@ +/****************************************************************************** + * 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 + *****************************************************************************/ + +export { GeneralDetails } from './general-details'; +export type { GeneralDetailsProps } from './general-details'; + +export { Members } from './members'; +export type { MembersProps } from './members'; + +export { UsageStats } from './usage-stats'; +export type { UsageStatsProps } from './usage-stats'; diff --git a/webui/src/components/rate-limiting/customer/members.tsx b/webui/src/components/rate-limiting/customer/members.tsx new file mode 100644 index 000000000..e9727b595 --- /dev/null +++ b/webui/src/components/rate-limiting/customer/members.tsx @@ -0,0 +1,68 @@ +/****************************************************************************** + * 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 { FC, type ReactNode } from 'react'; +import { + Box, + Typography, + Paper, + type PaperProps, + Divider, + Avatar, + List, + ListItem, + ListItemAvatar, + ListItemText, +} from '@mui/material'; +import type { UserData } from '../../../extension-registry-types'; + +const sectionPaperProps: PaperProps = { elevation: 1, sx: { p: 3, mb: 3 } }; + +export interface MembersProps { + users: UserData[]; + headerAction?: ReactNode; + renderUserAction?: (user: UserData) => ReactNode; + renderUserPrimary?: (user: UserData) => ReactNode; +} + +export const Members: FC = ({ users, headerAction, renderUserAction, renderUserPrimary }) => ( + + + Members + {headerAction && {headerAction}} + + + {users.length === 0 ? ( + + No members assigned to this customer. + + ) : ( + + {users.map(user => ( + + + + + + + ))} + + )} + +); diff --git a/webui/src/components/rate-limiting/customer/usage-stats.tsx b/webui/src/components/rate-limiting/customer/usage-stats.tsx new file mode 100644 index 000000000..71359ccd8 --- /dev/null +++ b/webui/src/components/rate-limiting/customer/usage-stats.tsx @@ -0,0 +1,50 @@ +/****************************************************************************** + * 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 { FC } from 'react'; +import { + Typography, + Paper, + type PaperProps, + Divider, +} from '@mui/material'; +import type { Customer, UsageStats as UsageStatsType } from '../../../extension-registry-types'; +import type { DateTime } from 'luxon'; +import { UsageStatsChart } from '../usage-stats/usage-stats-chart'; + +const sectionPaperProps: PaperProps = { elevation: 1, sx: { p: 3, mb: 3 } }; + +export interface UsageStatsProps { + usageStats: readonly UsageStatsType[]; + customer: Customer; + startDate: DateTime; + onStartDateChange: (date: DateTime) => void; + compact?: boolean; +} + +export const UsageStats: FC = ({ usageStats, customer, startDate, onStartDateChange, compact }) => ( + + + Usage Statistics + + + + +); diff --git a/webui/src/pages/admin-dashboard/usage-stats/usage-stats-chart.tsx b/webui/src/components/rate-limiting/usage-stats/usage-stats-chart.tsx similarity index 92% rename from webui/src/pages/admin-dashboard/usage-stats/usage-stats-chart.tsx rename to webui/src/components/rate-limiting/usage-stats/usage-stats-chart.tsx index 7db3a3c9d..735d97d98 100644 --- a/webui/src/pages/admin-dashboard/usage-stats/usage-stats-chart.tsx +++ b/webui/src/components/rate-limiting/usage-stats/usage-stats-chart.tsx @@ -42,6 +42,7 @@ interface UsageStatsChartProps { startDate: DateTime; onStartDateChange: (date: DateTime) => void; embedded?: boolean; + compact?: boolean; } export const UsageStatsChart: FC = ({ @@ -49,7 +50,8 @@ export const UsageStatsChart: FC = ({ customer, startDate, onStartDateChange, - embedded = false + embedded = false, + compact = false }) => { const dayStart = startDate.startOf('day').toMillis() / 1000; const dayEnd = startDate.endOf('day').toMillis() / 1000; @@ -134,8 +136,8 @@ export const UsageStatsChart: FC = ({ color: 'lightgray', }]} - height={400} - margin={{ top: 10 }} + height={compact ? 300 : 400} + margin={{ top: 30 }} xAxis={[ { id: 'date', @@ -172,11 +174,13 @@ export const UsageStatsChart: FC = ({ label='Time (UTC)' position='bottom' axisId='date' - tickInterval={(value, index) => { - return new Date(value).getMinutes() === 0; + tickInterval={(value) => { + const d = new Date(value); + return d.getMinutes() === 0 && (!compact || d.getHours() % 3 === 0); }} - tickLabelInterval={(value, index) => { - return new Date(value).getMinutes() === 0; + tickLabelInterval={(value) => { + const d = new Date(value); + return d.getMinutes() === 0 && (!compact || d.getHours() % 3 === 0); }} tickLabelStyle={{ fontSize: 10, @@ -202,4 +206,4 @@ export const UsageStatsChart: FC = ({ } ); -}; \ No newline at end of file +}; diff --git a/webui/src/pages/admin-dashboard/usage-stats/usage-stats-utils.ts b/webui/src/components/rate-limiting/usage-stats/usage-stats-utils.ts similarity index 100% rename from webui/src/pages/admin-dashboard/usage-stats/usage-stats-utils.ts rename to webui/src/components/rate-limiting/usage-stats/usage-stats-utils.ts diff --git a/webui/src/components/rate-limiting/usage-stats/use-usage-stats.ts b/webui/src/components/rate-limiting/usage-stats/use-usage-stats.ts new file mode 100644 index 000000000..7b2f2d852 --- /dev/null +++ b/webui/src/components/rate-limiting/usage-stats/use-usage-stats.ts @@ -0,0 +1,70 @@ +/****************************************************************************** + * 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, useState, useEffect, useRef, useCallback } from "react"; +import { MainContext } from "../../../context"; +import type { UsageStats } from "../../../extension-registry-types"; +import { handleError } from "../../../utils"; +import { getDefaultStartDate } from "./usage-stats-utils"; +import { DateTime } from "luxon"; + +export const useUsageStats = (customerName: string | undefined) => { + const abortController = useRef(new AbortController()); + const { service } = useContext(MainContext); + + const [usageStats, setUsageStats] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [internalStartDate, setInternalStartDate] = useState(getDefaultStartDate); + + const startDateRef = useRef(internalStartDate); + startDateRef.current = internalStartDate; + + const fetchUsageStats = useCallback(async (date: DateTime) => { + if (!customerName) { + setUsageStats([]); + setLoading(false); + return; + } + + try { + setLoading(true); + setError(null); + const data = await service.getUsageStatsForUser( + abortController.current, + customerName, + date.toJSDate() + ); + setUsageStats(data.stats); + } catch (err) { + setError(handleError(err as Error)); + } finally { + setLoading(false); + } + }, [service, customerName]); + + const setStartDate = useCallback((date: DateTime) => { + setInternalStartDate(date); + fetchUsageStats(date); + }, [fetchUsageStats]); + + useEffect(() => { + fetchUsageStats(startDateRef.current); + return () => { + abortController.current.abort(); + abortController.current = new AbortController(); + }; + }, [fetchUsageStats]); + + return { usageStats, loading, error, startDate: internalStartDate, setStartDate }; +}; diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index ad2638e51..8dc6f5fc7 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -15,11 +15,56 @@ import { LoginProviders, ScanResultJson, ScanCounts, ScanResultsResponse, ScanFilterOptions, FilesResponse, FileDecisionCountsJson, ScanDecisionRequest, ScanDecisionResponse, FileDecisionRequest, FileDecisionResponse, FileDecisionDeleteRequest, FileDecisionDeleteResponse, - Tier, TierList, Customer, CustomerList, UsageStatsList, LogPageableList, + Tier, TierList, Customer, CustomerList, UsageStats, UsageStatsList, LogPageableList, + EnforcementState, TierType, RefillStrategy, } from './extension-registry-types'; import { createAbsoluteURL, addQuery } from './utils'; import { sendRequest, ErrorResponse } from './server-request'; +// TODO: Remove mock users when backend returns real user data +const MOCK_USERS: UserData[] = [ + { + loginName: 'rhdevelopers-ci', + fullName: 'Red Hat Developers CI', + avatarUrl: 'https://avatars.githubusercontent.com/u/18214726?v=4', + homepage: 'https://github.com/rhdevelopers-ci', + provider: 'github', + tokensUrl: '', + createTokenUrl: '' + }, + { + loginName: 'midudev', + fullName: 'Miguel Ángel Durán', + avatarUrl: 'https://avatars.githubusercontent.com/u/1561955?v=4', + homepage: 'https://github.com/midudev', + provider: 'github', + tokensUrl: '', + createTokenUrl: '' + }, + { + loginName: 'jakubmisek', + fullName: 'Jakub Míšek', + avatarUrl: 'https://avatars.githubusercontent.com/u/842150?v=4', + homepage: 'https://github.com/jakubmisek', + provider: 'github', + tokensUrl: '', + createTokenUrl: '' + }, + { + loginName: 'test', + fullName: 'Miguel Ángel Durán', + avatarUrl: 'https://avatars.githubusercontent.com/u/1561955?v=4', + homepage: 'https://github.com/midudev', + provider: 'github', + tokensUrl: '', + createTokenUrl: '' + } +]; + +const injectMockUsers = (customer: Customer): Customer => { + return { ...customer, users: MOCK_USERS }; +}; + export class ExtensionRegistryService { readonly admin: AdminService; @@ -326,6 +371,75 @@ export class ExtensionRegistryService { }); } + // TODO: Replace with real user-scoped endpoint when backend is ready + async getCustomersForUser(_abortController: AbortController): Promise[]> { + return [ + { + name: 'eclipse', + state: EnforcementState.ENFORCEMENT, + tier: { + name: 'Enterprise', + description: 'Enterprise tier with higher rate limits', + tierType: TierType.NON_FREE, + capacity: 800, + duration: 300, + refillStrategy: RefillStrategy.GREEDY, + }, + cidrBlocks: ['192.168.1.0/24', '10.0.0.0/8'], + }, + { + name: 'test', + state: EnforcementState.EVALUATION, + tier: { + name: 'Free', + tierType: TierType.FREE, + capacity: 500, + duration: 300, + refillStrategy: RefillStrategy.INTERVAL, + }, + cidrBlocks: [], + }, + ].map(injectMockUsers); + } + + // TODO: Replace with real user-scoped endpoint when backend is ready + async getUsageStatsForUser(_abortController: AbortController, _customerName: string, date: Date): Promise> { + /** Generated using Gemini */ + const STEP = 5 * 60; // 5-minute windows in seconds + const dayStart = new Date(date); + dayStart.setUTCHours(0, 0, 0, 0); + const startEpoch = Math.floor(dayStart.getTime() / 1000); + + // Generate mock usage with a smooth traffic pattern (two overlapping peaks) + const stats: UsageStats[] = []; + // Simple seeded pseudo-random for deterministic but smooth noise + let seed = 42; + const rand = () => { + seed = (seed * 16807 + 0) % 2147483647; return seed / 2147483647; + }; + + for (let i = 0; i < 288; i++) { // 288 five-minute windows per day + const hour = i / 12; + // Two overlapping gaussian peaks for a more natural shape + const morning = Math.exp(-0.5 * Math.pow((hour - 10) / 2.5, 2)); + const afternoon = Math.exp(-0.5 * Math.pow((hour - 15) / 2, 2)); + const base = (morning * 500 + afternoon * 700); + // Smooth jitter: ±15% of base + const jitter = base * (rand() * 0.3 - 0.15); + const count = Math.round(Math.max(0, base + jitter)); + + if (count > 0) { + stats.push({ + windowStart: startEpoch + i * STEP, + duration: STEP, + count, + }); + } + } + + return { stats }; + } + getNamespaceMembers(abortController: AbortController, namespace: Namespace): Promise> { return sendRequest({ abortController, @@ -885,57 +999,13 @@ export class AdminServiceImpl implements AdminService { }, false); } - // TODO: Remove mock users when backend returns real user data - private static readonly MOCK_USERS: UserData[] = [ - { - loginName: 'rhdevelopers-ci', - fullName: 'Red Hat Developers CI', - avatarUrl: 'https://avatars.githubusercontent.com/u/18214726?v=4', - homepage: 'https://github.com/rhdevelopers-ci', - provider: 'github', - tokensUrl: '', - createTokenUrl: '' - }, - { - loginName: 'midudev', - fullName: 'Miguel Ángel Durán', - avatarUrl: 'https://avatars.githubusercontent.com/u/1561955?v=4', - homepage: 'https://github.com/midudev', - provider: 'github', - tokensUrl: '', - createTokenUrl: '' - }, - { - loginName: 'jakubmisek', - fullName: 'Jakub Míšek', - avatarUrl: 'https://avatars.githubusercontent.com/u/842150?v=4', - homepage: 'https://github.com/jakubmisek', - provider: 'github', - tokensUrl: '', - createTokenUrl: '' - }, - { - loginName: 'test', - fullName: 'Miguel Ángel Durán', - avatarUrl: 'https://avatars.githubusercontent.com/u/1561955?v=4', - homepage: 'https://github.com/midudev', - provider: 'github', - tokensUrl: '', - createTokenUrl: '' - } - ]; - - private injectMockUsers(customer: Customer): Customer { - return { ...customer, users: AdminServiceImpl.MOCK_USERS }; - } - async getCustomers(abortController: AbortController): Promise> { const data: CustomerList = await sendRequest({ abortController, endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'customers']), credentials: true }, false); - return { customers: data.customers.map(this.injectMockUsers) }; + return { customers: data.customers.map(injectMockUsers) }; } async getCustomer(abortController: AbortController, name: string): Promise> { @@ -944,7 +1014,7 @@ export class AdminServiceImpl implements AdminService { endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'customers', name]), credentials: true }, false); - return this.injectMockUsers(data); + return injectMockUsers(data); } async createCustomer(abortController: AbortController, customer: Customer): Promise> { diff --git a/webui/src/pages/admin-dashboard/customers/customer-details.tsx b/webui/src/pages/admin-dashboard/customers/customer-details.tsx index 52a4c80e3..a636100a6 100644 --- a/webui/src/pages/admin-dashboard/customers/customer-details.tsx +++ b/webui/src/pages/admin-dashboard/customers/customer-details.tsx @@ -15,20 +15,9 @@ import { FC, useContext, useState, useEffect, useRef, useCallback } from "react" import { Box, Typography, - Paper, - type PaperProps, - Chip, - Stack, - Alert, Button, - Divider, - Avatar, IconButton, - List, - ListItem, - ListItemAvatar, - ListItemText, - Grid, + Alert, LinearProgress } from "@mui/material"; import EditIcon from "@mui/icons-material/Edit"; @@ -36,17 +25,14 @@ import PersonAddIcon from "@mui/icons-material/PersonAdd"; import DeleteIcon from "@mui/icons-material/Delete"; import { useParams, Link as RouterLink } from "react-router-dom"; import { MainContext } from "../../../context"; -import type { Customer, UserData, UsageStats } from "../../../extension-registry-types"; -import type { DateTime } from "luxon"; -import { handleError } from "../../../utils"; +import type { Customer, UserData } from "../../../extension-registry-types"; +import { createRoute, handleError } from "../../../utils"; import { AdminDashboardRoutes } from "../admin-routes"; -import { UsageStatsChart } from "../usage-stats/usage-stats-chart"; -import { useUsageStats } from "../usage-stats/use-usage-stats"; +import { useAdminUsageStats } from "../usage-stats/use-usage-stats"; +import { GeneralDetails, Members, UsageStats } from "../../../components/rate-limiting/customer"; import { CustomerFormDialog } from "./customer-form-dialog"; import { AddUserDialog } from "../../user/add-user-dialog"; -const sectionPaperProps: PaperProps = { elevation: 1, sx: { p: 3, mb: 3 } }; - const CustomerDetailsLoading: FC = () => ( @@ -59,169 +45,6 @@ const CustomerDetailsError: FC<{ message: string }> = ({ message }) => ( ); -const GeneralInformationSection: FC<{ customer: Customer; onEdit: () => void }> = ({ customer, onEdit }) => { - const tier = customer.tier; - return ( - - - General Information - - - - - - Name - {customer.name} - - - State - - - - - {tier ? ( - <> - - Tier - - - - - - Tier Type - {tier.tierType} - - - Capacity - {tier.capacity} requests / {tier.duration}s - - - Refill Strategy - {tier.refillStrategy} - - {tier.description && ( - - Tier Description - {tier.description} - - )} - - ) : ( - - Tier - No tier assigned - - )} - - CIDR Blocks - {customer.cidrBlocks.length > 0 ? ( - - {customer.cidrBlocks.map((cidr) => ( - - ))} - - ) : ( - None configured - )} - - - - ); -}; - -interface MembersSectionProps { - users: UserData[]; - onAddUser: () => void; - onRemoveUser: (user: UserData) => void; -} - -const MembersSection: FC = ({ users, onAddUser, onRemoveUser }) => ( - - - Members - - - - {users.length === 0 ? ( - - No members assigned to this customer. - - ) : ( - - {users.map(user => ( - onRemoveUser(user)} - title='Remove member' - > - - - } - > - - - - - {user.loginName} - - } - secondary={user.fullName} - /> - - ))} - - )} - -); - -interface UsageStatsSectionProps { - usageStats: readonly UsageStats[]; - customer: Customer; - startDate: DateTime; - onStartDateChange: (date: DateTime) => void; -} - -const UsageStatsSection: FC = ({ usageStats, customer, startDate, onStartDateChange }) => ( - - - Usage Statistics - - - - -); - export const CustomerDetails: FC = () => { const { customer: customerName } = useParams<{ customer: string }>(); const abortController = useRef(new AbortController()); @@ -233,7 +56,7 @@ export const CustomerDetails: FC = () => { const [formDialogOpen, setFormDialogOpen] = useState(false); const [addUserDialogOpen, setAddUserDialogOpen] = useState(false); - const { usageStats, error: statsError, startDate, setStartDate } = useUsageStats(customerName); + const { usageStats, error: statsError, startDate, setStartDate } = useAdminUsageStats(customerName); const loadCustomer = useCallback(async () => { if (!customerName) return; @@ -295,9 +118,33 @@ export const CustomerDetails: FC = () => { - setFormDialogOpen(true)} /> - setAddUserDialogOpen(true)} onRemoveUser={handleRemoveUser} /> - + } onClick={() => setFormDialogOpen(true)}> + Edit + + } + /> + } onClick={() => setAddUserDialogOpen(true)}> + Add Member + + } + renderUserAction={(user) => ( + handleRemoveUser(user)} title='Remove member'> + + + )} + renderUserPrimary={(user) => ( + + {user.loginName} + + )} + /> + { const { customer } = useParams<{ customer: string }>(); @@ -33,7 +33,7 @@ export const UsageStatsView: FC = () => { const [customersLoading, setCustomersLoading] = useState(true); const [customersError, setCustomersError] = useState(null); - const { usageStats, loading, error: statsError, startDate, setStartDate } = useUsageStats(customer); + const { usageStats, loading, error: statsError, startDate, setStartDate } = useAdminUsageStats(customer); // Load customers for autocomplete useEffect(() => { diff --git a/webui/src/pages/admin-dashboard/usage-stats/use-usage-stats.ts b/webui/src/pages/admin-dashboard/usage-stats/use-usage-stats.ts index fa6d07ef7..c4724c871 100644 --- a/webui/src/pages/admin-dashboard/usage-stats/use-usage-stats.ts +++ b/webui/src/pages/admin-dashboard/usage-stats/use-usage-stats.ts @@ -15,10 +15,10 @@ import { useContext, useState, useEffect, useRef, useCallback } from "react"; import { MainContext } from "../../../context"; import type { UsageStats } from "../../../extension-registry-types"; import { handleError } from "../../../utils"; -import { getDefaultStartDate } from "./usage-stats-utils"; +import { getDefaultStartDate } from "../../../components/rate-limiting/usage-stats/usage-stats-utils"; import { DateTime } from "luxon"; -export const useUsageStats = (customerName: string | undefined) => { +export const useAdminUsageStats = (customerName: string | undefined) => { const abortController = useRef(new AbortController()); const { service } = useContext(MainContext); diff --git a/webui/src/pages/user/user-setting-tabs.tsx b/webui/src/pages/user/user-setting-tabs.tsx index b5fbfb645..da8f27ed2 100644 --- a/webui/src/pages/user/user-setting-tabs.tsx +++ b/webui/src/pages/user/user-setting-tabs.tsx @@ -42,6 +42,7 @@ export const UserSettingTabs = (): ReactElement => { + ); }; \ No newline at end of file diff --git a/webui/src/pages/user/user-settings-customer-detail.tsx b/webui/src/pages/user/user-settings-customer-detail.tsx new file mode 100644 index 000000000..1e091ff73 --- /dev/null +++ b/webui/src/pages/user/user-settings-customer-detail.tsx @@ -0,0 +1,39 @@ +/****************************************************************************** + * 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 { FC } from 'react'; +import { Box, Typography } from '@mui/material'; +import type { Customer } from '../../extension-registry-types'; +import { useUsageStats } from '../../components/rate-limiting/usage-stats/use-usage-stats'; +import { UsageStats } from '../../components/rate-limiting/customer'; + +export interface UserSettingsCustomerDetailProps { + customer: Customer; +} + +export const UserSettingsCustomerDetail: FC = ({ customer }) => { + const { usageStats, startDate, setStartDate } = useUsageStats(customer.name); + + return ( + + {customer.name} + + + ); +}; diff --git a/webui/src/pages/user/user-settings-customers.tsx b/webui/src/pages/user/user-settings-customers.tsx new file mode 100644 index 000000000..cf2040730 --- /dev/null +++ b/webui/src/pages/user/user-settings-customers.tsx @@ -0,0 +1,121 @@ +/****************************************************************************** + * 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, useEffect, useState, useRef } from 'react'; +import { Box, Typography, Tabs, Tab, useTheme, useMediaQuery } from '@mui/material'; +import { Customer } from '../../extension-registry-types'; +import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; +import { MainContext } from '../../context'; +import { UserSettingsCustomerDetail } from './user-settings-customer-detail'; + +interface CustomerTabsProps { + chosenCustomer: Customer; + onChange: (value: Customer) => void; + customers: Customer[]; +} + +const CustomersTabs = (props: CustomerTabsProps) => { + const theme = useTheme(); + const isATablet = useMediaQuery(theme.breakpoints.down('md')); + return ( + props.onChange(value)} + variant={isATablet ? 'scrollable' : 'standard'} + scrollButtons={isATablet ? 'auto' : false} + indicatorColor='secondary' + sx={{ width: { xs: '80%', sm: '80%', md: '80%', lg: '160px', xl: '160px' } }} + > + {props.customers.map(customer => ( + + ))} + + ); +}; + +export const UserSettingsCustomers: FunctionComponent = () => { + const [loading, setLoading] = useState(true); + const [customers, setCustomers] = useState([]); + const [chosenCustomer, setChosenCustomer] = useState(); + const { service, handleError } = useContext(MainContext); + const abortController = useRef(new AbortController()); + + useEffect(() => { + loadCustomers(); + return () => { + abortController.current.abort(); + }; + }, []); + + const loadCustomers = async (): Promise => { + try { + // TODO: Replace with user-scoped endpoint when backend is ready + const data = await service.getCustomersForUser(abortController.current); + const chosen = data.length ? data[0] : undefined; + setCustomers(data); + setChosenCustomer(chosen); + setLoading(false); + } catch (err) { + handleError(err); + setLoading(false); + } + }; + + let customerContainer: ReactNode = null; + if (customers.length > 0 && chosenCustomer) { + customerContainer = ( + + + + + ); + } else if (!loading) { + customerContainer = ( + + You are not a member of any rate limiting customer group. + + ); + } + + return ( + <> + + Rate Limiting + + + + {customerContainer} + + + ); +}; diff --git a/webui/src/pages/user/user-settings-routes.ts b/webui/src/pages/user/user-settings-routes.ts index 04fe581ef..15e4076d7 100644 --- a/webui/src/pages/user/user-settings-routes.ts +++ b/webui/src/pages/user/user-settings-routes.ts @@ -21,4 +21,5 @@ export namespace UserSettingsRoutes { export const NAMESPACES = createRoute([ROOT, 'namespaces']); export const EXTENSIONS = createRoute([ROOT, 'extensions']); export const DELETE_EXTENSION = createRoute([ROOT, 'extensions', ':namespace', ':extension', 'delete']); + export const CUSTOMERS = createRoute([ROOT, 'customers']); } diff --git a/webui/src/pages/user/user-settings.tsx b/webui/src/pages/user/user-settings.tsx index 10932a677..e3738c3c6 100644 --- a/webui/src/pages/user/user-settings.tsx +++ b/webui/src/pages/user/user-settings.tsx @@ -18,6 +18,7 @@ import { UserSettingsTokens } from './user-settings-tokens'; import { UserSettingsProfile } from './user-settings-profile'; import { UserSettingsNamespaces } from './user-settings-namespaces'; import { UserSettingsExtensions } from './user-settings-extensions'; +import { UserSettingsCustomers } from './user-settings-customers'; import { MainContext } from '../../context'; import { UserData } from '../../extension-registry-types'; import { LoginComponent } from '../../default/login'; @@ -42,6 +43,8 @@ export const UserSettings: FunctionComponent = props => { return ; case 'extensions': return ; + case 'customers': + return ; default: return null; } From 409219ca236aa0afaa1f35af32083d3fc2de5bef Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Mon, 23 Mar 2026 10:04:08 +0100 Subject: [PATCH 12/18] add CustomerMembership entity --- .../openvsx/entities/CustomerMembership.java | 87 +++++++++++++++++++ .../openvsx/json/CustomerMembershipJson.java | 22 +++++ .../json/CustomerMembershipListJson.java | 46 ++++++++++ .../db/migration/V1_67__Rate_Limit_P2.sql | 23 +++++ 4 files changed, 178 insertions(+) create mode 100644 server/src/main/java/org/eclipse/openvsx/entities/CustomerMembership.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/CustomerMembershipJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/CustomerMembershipListJson.java create mode 100644 server/src/main/resources/db/migration/V1_67__Rate_Limit_P2.sql diff --git a/server/src/main/java/org/eclipse/openvsx/entities/CustomerMembership.java b/server/src/main/java/org/eclipse/openvsx/entities/CustomerMembership.java new file mode 100644 index 000000000..ff475d30d --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/entities/CustomerMembership.java @@ -0,0 +1,87 @@ +/****************************************************************************** + * 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 + *****************************************************************************/ +package org.eclipse.openvsx.entities; + +import jakarta.persistence.*; +import org.eclipse.openvsx.json.CustomerMembershipJson; +import org.eclipse.openvsx.json.NamespaceMembershipJson; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Objects; + +@Entity +public class CustomerMembership implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(generator = "customerMembershipSeq") + @SequenceGenerator(name = "customerMembershipSeq", sequenceName = "customer_membership_seq", allocationSize = 1) + private long id; + + @ManyToOne + @JoinColumn(name = "customer") + private Customer customer; + + @ManyToOne + @JoinColumn(name = "user_data") + private UserData user; + + public CustomerMembershipJson toJson() { + return new CustomerMembershipJson( + this.customer.getName(), + this.user.toUserJson() + ); + } + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public UserData getUser() { + return user; + } + + public void setUser(UserData user) { + this.user = user; + } + + public Customer getCustomer() { + return customer; + } + + public void setCustomer(Customer customer) { + this.customer = customer; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + CustomerMembership that = (CustomerMembership) o; + return id == that.id + && Objects.equals(customer, that.customer) + && Objects.equals(user, that.user); + } + + @Override + public int hashCode() { + return Objects.hash(id, customer, user); + } +} \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/json/CustomerMembershipJson.java b/server/src/main/java/org/eclipse/openvsx/json/CustomerMembershipJson.java new file mode 100644 index 000000000..0f039ec96 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/CustomerMembershipJson.java @@ -0,0 +1,22 @@ +/****************************************************************************** + * 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 + *****************************************************************************/ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +@JsonInclude(Include.NON_NULL) +public record CustomerMembershipJson( + String customer, + UserJson user +) {} diff --git a/server/src/main/java/org/eclipse/openvsx/json/CustomerMembershipListJson.java b/server/src/main/java/org/eclipse/openvsx/json/CustomerMembershipListJson.java new file mode 100644 index 000000000..1b9469f57 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/CustomerMembershipListJson.java @@ -0,0 +1,46 @@ +/****************************************************************************** + * 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 + *****************************************************************************/ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +@Schema( + name = "CustomerMembershipList", + description = "Metadata of a customer member list" +) +@JsonInclude(Include.NON_NULL) +public class CustomerMembershipListJson extends ResultJson { + + public static CustomerMembershipListJson error(String message) { + var result = new CustomerMembershipListJson(); + result.setError(message); + return result; + } + + @Schema(description = "List of memberships") + @NotNull + private List customerMemberships; + + public List getNamespaceMemberships() { + return customerMemberships; + } + + public void setCustomerMemberships(List customerMemberships) { + this.customerMemberships = customerMemberships; + } +} diff --git a/server/src/main/resources/db/migration/V1_67__Rate_Limit_P2.sql b/server/src/main/resources/db/migration/V1_67__Rate_Limit_P2.sql new file mode 100644 index 000000000..99bdd4242 --- /dev/null +++ b/server/src/main/resources/db/migration/V1_67__Rate_Limit_P2.sql @@ -0,0 +1,23 @@ +-- database changes for Rate Limit phase 2 + +-- customer_membership table + +CREATE SEQUENCE IF NOT EXISTS customer_membership_seq START WITH 1 INCREMENT BY 1; + +CREATE TABLE IF NOT EXISTS public.customer_membership +( + id BIGINT NOT NULL PRIMARY KEY DEFAULT nextval('customer_membership_seq'), + customer bigint, + user_data bigint, + + -- foreign keys + + CONSTRAINT customer_membership_customer_fk FOREIGN KEY (customer) + REFERENCES public.customer(id) ON DELETE CASCADE, + + CONSTRAINT customer_membership_user_data_fk FOREIGN KEY (user_data) + REFERENCES public.user_data(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS customer_membership_namespace_idx ON public.customer_membership(customer); +CREATE INDEX IF NOT EXISTS customer_membership_user_data_idx ON public.customer_membership(user_data); From 9ebde93b1f35dbc369a879a438cceb99d15fb6d5 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Mon, 23 Mar 2026 21:04:21 +0100 Subject: [PATCH 13/18] add customer membership repository --- .../eclipse/openvsx/admin/RateLimitAPI.java | 30 ++++++++++++++++--- .../CustomerMembershipRepository.java | 21 +++++++++++++ .../repositories/RepositoryService.java | 7 +++++ 3 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 server/src/main/java/org/eclipse/openvsx/repositories/CustomerMembershipRepository.java diff --git a/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java index d2d5ba745..64ae592e7 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java @@ -12,17 +12,16 @@ *****************************************************************************/ package org.eclipse.openvsx.admin; -import org.eclipse.openvsx.entities.Customer; -import org.eclipse.openvsx.entities.Tier; -import org.eclipse.openvsx.entities.TierType; -import org.eclipse.openvsx.entities.UsageStats; +import org.eclipse.openvsx.entities.*; import org.eclipse.openvsx.json.*; import org.eclipse.openvsx.ratelimit.cache.RateLimitCacheService; import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.util.ErrorResultException; import org.eclipse.openvsx.util.LogService; import org.eclipse.openvsx.util.TimeUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -326,6 +325,29 @@ public ResponseEntity updateCustomer(@PathVariable String name, @R } } + @GetMapping( + path = "/customers/{name}/members", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity getCustomerMembers(@PathVariable String name) { + try { + admins.checkAdminUser(); + + var customer = repositories.findCustomer(name); + if (customer == null) { + return ResponseEntity.notFound().build(); + } + + var memberships = repositories.findCustomerMemberships(customer); + var membershipList = new CustomerMembershipListJson(); + membershipList.setCustomerMemberships(memberships.stream().map(CustomerMembership::toJson).toList()); + return ResponseEntity.ok(membershipList); + } catch (Exception exc) { + logger.error("failed retrieving customer members {}", name, exc); + return ResponseEntity.internalServerError().build(); + } + } + @DeleteMapping( path = "/customers/{name}", produces = MediaType.APPLICATION_JSON_VALUE diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/CustomerMembershipRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/CustomerMembershipRepository.java new file mode 100644 index 000000000..ac8c91b7c --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/repositories/CustomerMembershipRepository.java @@ -0,0 +1,21 @@ +/****************************************************************************** + * 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 + *****************************************************************************/ +package org.eclipse.openvsx.repositories; + +import org.eclipse.openvsx.entities.*; +import org.springframework.data.repository.Repository; +import org.springframework.data.util.Streamable; + +public interface CustomerMembershipRepository extends Repository { + Streamable findByCustomer(Customer customer); +} 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 cb13c80e9..bad0fe861 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -76,6 +76,7 @@ public class RepositoryService { private final ScanCheckResultRepository scanCheckResultRepo; private final TierRepository tierRepo; private final CustomerRepository customerRepo; + private final CustomerMembershipRepository customerMembershipRepo; private final UsageStatsRepository usageStatsRepository; public RepositoryService( @@ -110,6 +111,7 @@ public RepositoryService( ScanCheckResultRepository scanCheckResultRepo, TierRepository tierRepo, CustomerRepository customerRepo, + CustomerMembershipRepository customerMembershipRepo, UsageStatsRepository usageStatsRepository ) { this.namespaceRepo = namespaceRepo; @@ -143,6 +145,7 @@ public RepositoryService( this.scanCheckResultRepo = scanCheckResultRepo; this.tierRepo = tierRepo; this.customerRepo = customerRepo; + this.customerMembershipRepo = customerMembershipRepo; this.usageStatsRepository = usageStatsRepository; } @@ -1276,6 +1279,10 @@ public void deleteCustomer(Customer customer) { customerRepo.delete(customer); } + public Streamable findCustomerMemberships(Customer customer) { + return customerMembershipRepo.findByCustomer(customer); + } + public List findUsageStatsByCustomerAndDate(Customer customer, LocalDateTime date) { var startTime = date.truncatedTo(ChronoUnit.DAYS).minusMinutes(5); var endTime = date.truncatedTo(ChronoUnit.DAYS).plusDays(1); From 85a7c61fc3bdf4dcc559b76fee50e11216fc506d Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Tue, 24 Mar 2026 18:26:39 +0100 Subject: [PATCH 14/18] support editing customer membership --- .../eclipse/openvsx/admin/RateLimitAPI.java | 52 +++++- .../json/CustomerMembershipListJson.java | 2 +- .../openvsx/ratelimit/CustomerService.java | 55 +++++- .../CustomerMembershipRepository.java | 2 + .../repositories/RepositoryService.java | 4 + .../ratelimit/CustomerServiceTest.java | 8 +- .../RepositoryServiceSmokeTest.java | 2 + .../rate-limiting/customer/index.ts | 3 - .../rate-limiting/customer/members.tsx | 68 ------- webui/src/extension-registry-service.ts | 153 +++++++-------- webui/src/extension-registry-types.ts | 10 +- .../customers/customer-details.tsx | 60 ++---- .../customers/customer-form-dialog.tsx | 7 +- .../customers/customer-member-list.tsx | 174 ++++++++++++++++++ .../admin-dashboard/customers/customers.tsx | 25 +-- 15 files changed, 396 insertions(+), 229 deletions(-) delete mode 100644 webui/src/components/rate-limiting/customer/members.tsx create mode 100644 webui/src/pages/admin-dashboard/customers/customer-member-list.tsx diff --git a/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java b/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java index 64ae592e7..f0d8c22a1 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java @@ -14,6 +14,7 @@ import org.eclipse.openvsx.entities.*; import org.eclipse.openvsx.json.*; +import org.eclipse.openvsx.ratelimit.CustomerService; import org.eclipse.openvsx.ratelimit.cache.RateLimitCacheService; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.util.ErrorResultException; @@ -21,12 +22,10 @@ import org.eclipse.openvsx.util.TimeUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.time.LocalDateTime; import java.util.Optional; @@ -38,17 +37,20 @@ public class RateLimitAPI { private final RepositoryService repositories; private final AdminService admins; private final LogService logs; + private final CustomerService customerService; private RateLimitCacheService rateLimitCacheService; public RateLimitAPI( RepositoryService repositories, AdminService admins, LogService logs, + CustomerService customerService, Optional rateLimitCacheService ) { this.repositories = repositories; this.admins = admins; this.logs = logs; + this.customerService = customerService; rateLimitCacheService.ifPresent(service -> this.rateLimitCacheService = service); } @@ -348,6 +350,52 @@ public ResponseEntity getCustomerMembers(@PathVariab } } + @PostMapping( + path = "/customers/{name}/add-member", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity addCustomerMember( + @PathVariable String name, + @RequestParam("user") String userName, + @RequestParam(required = false) String provider + ) { + try { + var admin = admins.checkAdminUser(); + + var result = customerService.addCustomerMember(name, userName, provider); + logs.logAction(admin, result); + return ResponseEntity.ok(result); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(); + } catch (Exception exc) { + logger.error("failed adding user {} to customer {}", userName, name, exc); + return ResponseEntity.internalServerError().build(); + } + } + + @PostMapping( + path = "/customers/{name}/remove-member", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity removeCustomerMember( + @PathVariable String name, + @RequestParam("user") String userName, + @RequestParam(required = false) String provider + ) { + try { + var admin = admins.checkAdminUser(); + + var result = customerService.removeCustomerMember(name, userName, provider); + logs.logAction(admin, result); + return ResponseEntity.ok(result); + } catch (ErrorResultException exc) { + return exc.toResponseEntity(); + } catch (Exception exc) { + logger.error("failed removing user {} from customer {}", userName, name, exc); + return ResponseEntity.internalServerError().build(); + } + } + @DeleteMapping( path = "/customers/{name}", produces = MediaType.APPLICATION_JSON_VALUE diff --git a/server/src/main/java/org/eclipse/openvsx/json/CustomerMembershipListJson.java b/server/src/main/java/org/eclipse/openvsx/json/CustomerMembershipListJson.java index 1b9469f57..28f7f1bb3 100644 --- a/server/src/main/java/org/eclipse/openvsx/json/CustomerMembershipListJson.java +++ b/server/src/main/java/org/eclipse/openvsx/json/CustomerMembershipListJson.java @@ -36,7 +36,7 @@ public static CustomerMembershipListJson error(String message) { @NotNull private List customerMemberships; - public List getNamespaceMemberships() { + public List getCustomerMemberships() { return customerMemberships; } diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerService.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerService.java index f3b3d242e..d71bd8091 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerService.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerService.java @@ -15,11 +15,18 @@ import inet.ipaddr.IPAddressString; import inet.ipaddr.ipv4.IPv4AddressAssociativeTrie; import jakarta.annotation.PostConstruct; +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; import org.eclipse.openvsx.entities.Customer; +import org.eclipse.openvsx.entities.CustomerMembership; +import org.eclipse.openvsx.entities.NamespaceMembership; +import org.eclipse.openvsx.entities.UserData; +import org.eclipse.openvsx.json.ResultJson; import org.eclipse.openvsx.ratelimit.cache.ConfigurationChanged; import org.eclipse.openvsx.ratelimit.cache.RateLimitCacheService; import org.eclipse.openvsx.ratelimit.config.RateLimitConfig; import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.util.ErrorResultException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @@ -30,15 +37,16 @@ import java.util.Optional; @Service -@ConditionalOnBean(RateLimitConfig.class) public class CustomerService { private final Logger logger = LoggerFactory.getLogger(CustomerService.class); + private final EntityManager entityManager; private final RepositoryService repositories; private IPv4AddressAssociativeTrie customersByIPAddress; - public CustomerService(RepositoryService repositories) { + public CustomerService(EntityManager entityManager, RepositoryService repositories) { + this.entityManager = entityManager; this.repositories = repositories; } @@ -84,4 +92,47 @@ private IPv4AddressAssociativeTrie rebuildIPAddressCache() { } return trie; } + + @Transactional(rollbackOn = ErrorResultException.class) + public ResultJson addCustomerMember(String customerName, String userName, String provider) throws ErrorResultException { + var customer = repositories.findCustomer(customerName); + if (customer == null) { + throw new ErrorResultException("Customer not found: " + customerName); + } + var user = repositories.findUserByLoginName(provider, userName); + if (user == null) { + throw new ErrorResultException("User not found: " + (provider + "/" + userName)); + } + + var membership = repositories.findCustomerMembership(user, customer); + if (membership != null) { + throw new ErrorResultException("User " + user.getLoginName() + " is already member of customer " + customer.getName() + "."); + } + + membership = new CustomerMembership(); + membership.setCustomer(customer); + membership.setUser(user); + entityManager.persist(membership); + return ResultJson.success("Added " + user.getLoginName() + " as member of customer " + customer.getName() + "."); + } + + @Transactional(rollbackOn = ErrorResultException.class) + public ResultJson removeCustomerMember(String customerName, String userName, String provider) throws ErrorResultException { + var customer = repositories.findCustomer(customerName); + if (customer == null) { + throw new ErrorResultException("Customer not found: " + customerName); + } + var user = repositories.findUserByLoginName(provider, userName); + if (user == null) { + throw new ErrorResultException("User not found: " + (provider + "/" + userName)); + } + + var membership = repositories.findCustomerMembership(user, customer); + if (membership == null) { + throw new ErrorResultException("User " + user.getLoginName() + " is not a member of customer " + customer.getName() + "."); + } + + entityManager.remove(membership); + return ResultJson.success("Removed " + user.getLoginName() + " as member of customer " + customer.getName() + "."); + } } diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/CustomerMembershipRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/CustomerMembershipRepository.java index ac8c91b7c..b33a1d4a8 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/CustomerMembershipRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/CustomerMembershipRepository.java @@ -18,4 +18,6 @@ public interface CustomerMembershipRepository extends Repository { Streamable findByCustomer(Customer customer); + + CustomerMembership findByUserAndCustomer(UserData user, Customer customer); } 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 bad0fe861..2ebc33608 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -1283,6 +1283,10 @@ public Streamable findCustomerMemberships(Customer customer) return customerMembershipRepo.findByCustomer(customer); } + public CustomerMembership findCustomerMembership(UserData user, Customer customer) { + return customerMembershipRepo.findByUserAndCustomer(user, customer); + } + public List findUsageStatsByCustomerAndDate(Customer customer, LocalDateTime date) { var startTime = date.truncatedTo(ChronoUnit.DAYS).minusMinutes(5); var endTime = date.truncatedTo(ChronoUnit.DAYS).plusDays(1); diff --git a/server/src/test/java/org/eclipse/openvsx/ratelimit/CustomerServiceTest.java b/server/src/test/java/org/eclipse/openvsx/ratelimit/CustomerServiceTest.java index e78e3406b..143ef317d 100644 --- a/server/src/test/java/org/eclipse/openvsx/ratelimit/CustomerServiceTest.java +++ b/server/src/test/java/org/eclipse/openvsx/ratelimit/CustomerServiceTest.java @@ -12,6 +12,7 @@ *****************************************************************************/ package org.eclipse.openvsx.ratelimit; +import jakarta.persistence.EntityManager; import org.eclipse.openvsx.entities.Customer; import org.eclipse.openvsx.repositories.RepositoryService; import org.junit.jupiter.api.Test; @@ -30,6 +31,9 @@ @ExtendWith(SpringExtension.class) public class CustomerServiceTest { + @MockitoBean + EntityManager entityManager; + @MockitoBean RepositoryService repositories; @@ -59,8 +63,8 @@ private Customer mockCustomer() { @TestConfiguration static class TestConfig { @Bean - public CustomerService customerService(RepositoryService repositoryService) { - return new CustomerService(repositoryService); + public CustomerService customerService(EntityManager entityManager, RepositoryService repositoryService) { + return new CustomerService(entityManager, repositoryService); } } } diff --git a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java index 761e83453..addcbe88a 100644 --- a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java +++ b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java @@ -380,6 +380,8 @@ void testExecuteQueries() { () -> repositories.findCustomersByTier(tier), () -> repositories.countCustomersByTier(tier), () -> repositories.findAllCustomers(), + () -> repositories.findCustomerMemberships(customer), + () -> repositories.findCustomerMembership(userData, customer), () -> repositories.saveUsageStats(usageStats), () -> repositories.findUsageStatsByCustomerAndDate(customer, NOW), () -> repositories.deleteTier(tier), diff --git a/webui/src/components/rate-limiting/customer/index.ts b/webui/src/components/rate-limiting/customer/index.ts index 6a8709950..882d9eddd 100644 --- a/webui/src/components/rate-limiting/customer/index.ts +++ b/webui/src/components/rate-limiting/customer/index.ts @@ -14,8 +14,5 @@ export { GeneralDetails } from './general-details'; export type { GeneralDetailsProps } from './general-details'; -export { Members } from './members'; -export type { MembersProps } from './members'; - export { UsageStats } from './usage-stats'; export type { UsageStatsProps } from './usage-stats'; diff --git a/webui/src/components/rate-limiting/customer/members.tsx b/webui/src/components/rate-limiting/customer/members.tsx deleted file mode 100644 index e9727b595..000000000 --- a/webui/src/components/rate-limiting/customer/members.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/****************************************************************************** - * 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 { FC, type ReactNode } from 'react'; -import { - Box, - Typography, - Paper, - type PaperProps, - Divider, - Avatar, - List, - ListItem, - ListItemAvatar, - ListItemText, -} from '@mui/material'; -import type { UserData } from '../../../extension-registry-types'; - -const sectionPaperProps: PaperProps = { elevation: 1, sx: { p: 3, mb: 3 } }; - -export interface MembersProps { - users: UserData[]; - headerAction?: ReactNode; - renderUserAction?: (user: UserData) => ReactNode; - renderUserPrimary?: (user: UserData) => ReactNode; -} - -export const Members: FC = ({ users, headerAction, renderUserAction, renderUserPrimary }) => ( - - - Members - {headerAction && {headerAction}} - - - {users.length === 0 ? ( - - No members assigned to this customer. - - ) : ( - - {users.map(user => ( - - - - - - - ))} - - )} - -); diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index 8dc6f5fc7..152410cda 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -16,55 +16,11 @@ import { FilesResponse, FileDecisionCountsJson, ScanDecisionRequest, ScanDecisionResponse, FileDecisionRequest, FileDecisionResponse, FileDecisionDeleteRequest, FileDecisionDeleteResponse, Tier, TierList, Customer, CustomerList, UsageStats, UsageStatsList, LogPageableList, - EnforcementState, TierType, RefillStrategy, + EnforcementState, TierType, RefillStrategy, CustomerMembershipList, } from './extension-registry-types'; import { createAbsoluteURL, addQuery } from './utils'; import { sendRequest, ErrorResponse } from './server-request'; -// TODO: Remove mock users when backend returns real user data -const MOCK_USERS: UserData[] = [ - { - loginName: 'rhdevelopers-ci', - fullName: 'Red Hat Developers CI', - avatarUrl: 'https://avatars.githubusercontent.com/u/18214726?v=4', - homepage: 'https://github.com/rhdevelopers-ci', - provider: 'github', - tokensUrl: '', - createTokenUrl: '' - }, - { - loginName: 'midudev', - fullName: 'Miguel Ángel Durán', - avatarUrl: 'https://avatars.githubusercontent.com/u/1561955?v=4', - homepage: 'https://github.com/midudev', - provider: 'github', - tokensUrl: '', - createTokenUrl: '' - }, - { - loginName: 'jakubmisek', - fullName: 'Jakub Míšek', - avatarUrl: 'https://avatars.githubusercontent.com/u/842150?v=4', - homepage: 'https://github.com/jakubmisek', - provider: 'github', - tokensUrl: '', - createTokenUrl: '' - }, - { - loginName: 'test', - fullName: 'Miguel Ángel Durán', - avatarUrl: 'https://avatars.githubusercontent.com/u/1561955?v=4', - homepage: 'https://github.com/midudev', - provider: 'github', - tokensUrl: '', - createTokenUrl: '' - } -]; - -const injectMockUsers = (customer: Customer): Customer => { - return { ...customer, users: MOCK_USERS }; -}; - export class ExtensionRegistryService { readonly admin: AdminService; @@ -73,7 +29,7 @@ export class ExtensionRegistryService { this.admin = new AdminConstructor(this); } - getLoginProviders(abortController: AbortController): Promise> { + async getLoginProviders(abortController: AbortController): Promise> { const endpoint = createAbsoluteURL([this.serverUrl, 'login-providers']); return sendRequest({ abortController, endpoint }); } @@ -94,7 +50,7 @@ export class ExtensionRegistryService { return createAbsoluteURL(arr); } - getNamespaceDetails(abortController: AbortController, name: string): Promise> { + async getNamespaceDetails(abortController: AbortController, name: string): Promise> { const endpoint = createAbsoluteURL([this.serverUrl, 'api', name, 'details']); return sendRequest({ abortController, endpoint }); } @@ -140,7 +96,7 @@ export class ExtensionRegistryService { }); } - search(abortController: AbortController, filter?: ExtensionFilter): Promise> { + async search(abortController: AbortController, filter?: ExtensionFilter): Promise> { const query: { key: string, value: string | number }[] = []; if (filter) { if (filter.query) @@ -160,11 +116,11 @@ export class ExtensionRegistryService { return sendRequest({ abortController, endpoint }); } - getExtensionDetail(abortController: AbortController, extensionUrl: UrlString): Promise> { + async getExtensionDetail(abortController: AbortController, extensionUrl: UrlString): Promise> { return sendRequest({ abortController, endpoint: extensionUrl }); } - getExtensionReadme(abortController: AbortController, extension: Extension): Promise { + async getExtensionReadme(abortController: AbortController, extension: Extension): Promise { return sendRequest({ abortController, endpoint: extension.files.readme, @@ -173,7 +129,7 @@ export class ExtensionRegistryService { }); } - getExtensionChangelog(abortController: AbortController, extension: Extension): Promise { + async getExtensionChangelog(abortController: AbortController, extension: Extension): Promise { return sendRequest({ abortController, endpoint: extension.files.changelog, @@ -182,7 +138,7 @@ export class ExtensionRegistryService { }); } - getExtensionIcon(abortController: AbortController, extension: Extension | SearchEntry): Promise { + async getExtensionIcon(abortController: AbortController, extension: Extension | SearchEntry): Promise { if (!extension.files.icon) { return Promise.resolve(undefined); } @@ -218,7 +174,7 @@ export class ExtensionRegistryService { ]; } - getExtensionReviews(abortController: AbortController, extension: Extension): Promise> { + async getExtensionReviews(abortController: AbortController, extension: Extension): Promise> { return sendRequest({ abortController, endpoint: extension.reviewsUrl }); } @@ -273,7 +229,7 @@ export class ExtensionRegistryService { }); } - getUser(abortController: AbortController): Promise> { + async getUser(abortController: AbortController): Promise> { return sendRequest({ abortController, endpoint: createAbsoluteURL([this.serverUrl, 'user']), @@ -281,7 +237,7 @@ export class ExtensionRegistryService { }); } - getUserAuthError(abortController: AbortController): Promise> { + async getUserAuthError(abortController: AbortController): Promise> { return sendRequest({ abortController, endpoint: createAbsoluteURL([this.serverUrl, 'user', 'auth-error']), @@ -289,7 +245,7 @@ export class ExtensionRegistryService { }); } - getUserByName(abortController: AbortController, name: string): Promise[]> { + async getUserByName(abortController: AbortController, name: string): Promise[]> { return sendRequest({ abortController, endpoint: createAbsoluteURL([this.serverUrl, 'user', 'search', name]), @@ -297,7 +253,7 @@ export class ExtensionRegistryService { }); } - getAccessTokens(abortController: AbortController, user: UserData): Promise[]> { + async getAccessTokens(abortController: AbortController, user: UserData): Promise[]> { return sendRequest({ abortController, credentials: true, @@ -355,7 +311,7 @@ export class ExtensionRegistryService { }))); } - getCsrfToken(abortController: AbortController): Promise> { + async getCsrfToken(abortController: AbortController): Promise> { return sendRequest({ abortController, credentials: true, @@ -363,7 +319,7 @@ export class ExtensionRegistryService { }); } - getNamespaces(abortController: AbortController): Promise[]> { + async getNamespaces(abortController: AbortController): Promise[]> { return sendRequest({ abortController, credentials: true, @@ -399,7 +355,7 @@ export class ExtensionRegistryService { }, cidrBlocks: [], }, - ].map(injectMockUsers); + ]; } // TODO: Replace with real user-scoped endpoint when backend is ready @@ -440,7 +396,7 @@ export class ExtensionRegistryService { return { stats }; } - getNamespaceMembers(abortController: AbortController, namespace: Namespace): Promise> { + async getNamespaceMembers(abortController: AbortController, namespace: Namespace): Promise> { return sendRequest({ abortController, credentials: true, @@ -485,7 +441,7 @@ export class ExtensionRegistryService { }); } - getStaticContent(abortController: AbortController, url: string): Promise { + async getStaticContent(abortController: AbortController, url: string): Promise { return sendRequest({ abortController, endpoint: url, @@ -623,6 +579,9 @@ export interface AdminService { createCustomer(abortController: AbortController, customer: Customer): Promise>; updateCustomer(abortController: AbortController, name: string, customer: Customer): Promise>; deleteCustomer(abortController: AbortController, name: string): Promise>; + getCustomerMembers(abortController: AbortController, name: string): Promise>; + addCustomerMember(abortController: AbortController, name: string, user: UserData): Promise>; + removeCustomerMember(abortController: AbortController, name: string, user: UserData): Promise>; getUsageStats(abortController: AbortController, customerName: string, date: Date): Promise>; getLogs(abortController: AbortController, page?: number, size?: number, period?: string): Promise>; } @@ -635,7 +594,7 @@ export class AdminServiceImpl implements AdminService { constructor(readonly registry: ExtensionRegistryService) {} - getExtension(abortController: AbortController, namespace: string, extension: string): Promise> { + async getExtension(abortController: AbortController, namespace: string, extension: string): Promise> { return sendRequest({ abortController, credentials: true, @@ -663,7 +622,7 @@ export class AdminServiceImpl implements AdminService { }); } - getNamespace(abortController: AbortController, name: string): Promise> { + async getNamespace(abortController: AbortController, name: string): Promise> { return sendRequest({ abortController, credentials: true, @@ -749,7 +708,7 @@ export class AdminServiceImpl implements AdminService { }); } - getAllScans(abortController: AbortController, params?: { size?: number; offset?: number; status?: string | string[]; publisher?: string; namespace?: string; name?: string; validationType?: string[]; threatScannerName?: string[]; dateStartedFrom?: string; dateStartedTo?: string; enforcement?: 'enforced' | 'notEnforced' | 'all'; adminDecision?: string[] }): Promise> { + async getAllScans(abortController: AbortController, params?: { size?: number; offset?: number; status?: string | string[]; publisher?: string; namespace?: string; name?: string; validationType?: string[]; threatScannerName?: string[]; dateStartedFrom?: string; dateStartedTo?: string; enforcement?: 'enforced' | 'notEnforced' | 'all'; adminDecision?: string[] }): Promise> { const query: { key: string, value: string | number }[] = []; if (params) { if (params.size !== undefined) @@ -787,7 +746,7 @@ export class AdminServiceImpl implements AdminService { }); } - getScan(abortController: AbortController, scanId: string): Promise> { + async getScan(abortController: AbortController, scanId: string): Promise> { return sendRequest({ abortController, credentials: true, @@ -795,7 +754,7 @@ export class AdminServiceImpl implements AdminService { }); } - getScanCounts(abortController: AbortController, params?: { dateStartedFrom?: string; dateStartedTo?: string; enforcement?: 'enforced' | 'notEnforced' | 'all'; threatScannerName?: string[]; validationType?: string[] }): Promise> { + async getScanCounts(abortController: AbortController, params?: { dateStartedFrom?: string; dateStartedTo?: string; enforcement?: 'enforced' | 'notEnforced' | 'all'; threatScannerName?: string[]; validationType?: string[] }): Promise> { const query: { key: string, value: string | number }[] = []; if (params) { if (params.dateStartedFrom) @@ -819,7 +778,7 @@ export class AdminServiceImpl implements AdminService { }); } - getScanFilterOptions(abortController: AbortController): Promise> { + async getScanFilterOptions(abortController: AbortController): Promise> { return sendRequest({ abortController, credentials: true, @@ -827,7 +786,7 @@ export class AdminServiceImpl implements AdminService { }); } - getFiles(abortController: AbortController, params?: { size?: number; offset?: number; decision?: string; publisher?: string; namespace?: string; name?: string; dateDecidedFrom?: string; dateDecidedTo?: string; sortBy?: string; sortOrder?: 'asc' | 'desc' }): Promise> { + async getFiles(abortController: AbortController, params?: { size?: number; offset?: number; decision?: string; publisher?: string; namespace?: string; name?: string; dateDecidedFrom?: string; dateDecidedTo?: string; sortBy?: string; sortOrder?: 'asc' | 'desc' }): Promise> { const query: { key: string, value: string | number }[] = []; if (params) { if (params.size !== undefined) @@ -859,7 +818,7 @@ export class AdminServiceImpl implements AdminService { }); } - getFileCounts(abortController: AbortController, params?: { dateDecidedFrom?: string; dateDecidedTo?: string }): Promise> { + async getFileCounts(abortController: AbortController, params?: { dateDecidedFrom?: string; dateDecidedTo?: string }): Promise> { const query: { key: string, value: string | number }[] = []; if (params) { if (params.dateDecidedFrom) @@ -1000,21 +959,19 @@ export class AdminServiceImpl implements AdminService { } async getCustomers(abortController: AbortController): Promise> { - const data: CustomerList = await sendRequest({ + return await sendRequest({ abortController, endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'customers']), credentials: true }, false); - return { customers: data.customers.map(injectMockUsers) }; } async getCustomer(abortController: AbortController, name: string): Promise> { - const data: Customer = await sendRequest({ + return await sendRequest({ abortController, endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'customers', name]), credentials: true }, false); - return injectMockUsers(data); } async createCustomer(abortController: AbortController, customer: Customer): Promise> { @@ -1073,6 +1030,54 @@ export class AdminServiceImpl implements AdminService { }, false); } + async getCustomerMembers(abortController: AbortController, name: string): Promise> { + return sendRequest({ + abortController, + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'customers', name, "members"]), + }, false); + } + + async addCustomerMember(abortController: AbortController, name: string, user: UserData): Promise> { + const csrfResponse = await this.registry.getCsrfToken(abortController); + const headers: Record = {}; + if (!isError(csrfResponse)) { + const csrfToken = csrfResponse as CsrfTokenJson; + headers[csrfToken.header] = csrfToken.value; + } + const query = [ + { key: 'user', value: user.loginName }, + { key: 'provider', value: user.provider }, + ]; + return sendRequest({ + abortController, + headers, + method: 'POST', + credentials: true, + endpoint: addQuery(createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'customers', name, "add-member"]), query), + }, false); + } + + async removeCustomerMember(abortController: AbortController, name: string, user: UserData): Promise> { + const csrfResponse = await this.registry.getCsrfToken(abortController); + const headers: Record = {}; + if (!isError(csrfResponse)) { + const csrfToken = csrfResponse as CsrfTokenJson; + headers[csrfToken.header] = csrfToken.value; + } + const query = [ + { key: 'user', value: user.loginName }, + { key: 'provider', value: user.provider }, + ]; + return sendRequest({ + abortController, + headers, + method: 'POST', + credentials: true, + endpoint: addQuery(createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'customers', name, "remove-member"]), query), + }, false); + } + /** * Get usage stats for a customer within an optional date range. */ diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index 09f3fbe6c..be2c9ba28 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -452,13 +452,21 @@ export interface Customer { tier?: Tier; state: EnforcementState; cidrBlocks: string[]; - users?: UserData[]; } export interface CustomerList { customers: Customer[]; } +export interface CustomerMembership { + customer: string; + user: UserData; +} + +export interface CustomerMembershipList { + customerMemberships: CustomerMembership[]; +} + export interface UsageStats { windowStart: number; // epoch seconds in UTC duration: number; // in seconds diff --git a/webui/src/pages/admin-dashboard/customers/customer-details.tsx b/webui/src/pages/admin-dashboard/customers/customer-details.tsx index a636100a6..d563066c0 100644 --- a/webui/src/pages/admin-dashboard/customers/customer-details.tsx +++ b/webui/src/pages/admin-dashboard/customers/customer-details.tsx @@ -16,22 +16,18 @@ import { Box, Typography, Button, - IconButton, Alert, LinearProgress } from "@mui/material"; -import EditIcon from "@mui/icons-material/Edit"; -import PersonAddIcon from "@mui/icons-material/PersonAdd"; -import DeleteIcon from "@mui/icons-material/Delete"; -import { useParams, Link as RouterLink } from "react-router-dom"; -import { MainContext } from "../../../context"; -import type { Customer, UserData } from "../../../extension-registry-types"; -import { createRoute, handleError } from "../../../utils"; -import { AdminDashboardRoutes } from "../admin-routes"; -import { useAdminUsageStats } from "../usage-stats/use-usage-stats"; -import { GeneralDetails, Members, UsageStats } from "../../../components/rate-limiting/customer"; -import { CustomerFormDialog } from "./customer-form-dialog"; -import { AddUserDialog } from "../../user/add-user-dialog"; +import EditIcon from '@mui/icons-material/Edit'; +import { useParams } from 'react-router-dom'; +import { MainContext } from '../../../context'; +import type { Customer } from '../../../extension-registry-types'; +import { handleError } from '../../../utils'; +import { useAdminUsageStats } from '../usage-stats/use-usage-stats'; +import { GeneralDetails, UsageStats } from '../../../components/rate-limiting/customer'; +import { CustomerFormDialog } from './customer-form-dialog'; +import { CustomerMemberList } from './customer-member-list'; const CustomerDetailsLoading: FC = () => ( @@ -54,7 +50,6 @@ export const CustomerDetails: FC = () => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [formDialogOpen, setFormDialogOpen] = useState(false); - const [addUserDialogOpen, setAddUserDialogOpen] = useState(false); const { usageStats, error: statsError, startDate, setStartDate } = useAdminUsageStats(customerName); @@ -88,15 +83,6 @@ export const CustomerDetails: FC = () => { } }; - const users = customer?.users ?? []; - - // TODO: Replace with real API calls when backend is ready - const handleAddUser = (user: UserData) => { - }; - - const handleRemoveUser = (user: UserData) => { - }; - if (loading) { return ; } @@ -126,23 +112,8 @@ export const CustomerDetails: FC = () => { } /> - } onClick={() => setAddUserDialogOpen(true)}> - Add Member - - } - renderUserAction={(user) => ( - handleRemoveUser(user)} title='Remove member'> - - - )} - renderUserPrimary={(user) => ( - - {user.loginName} - - )} + @@ -153,15 +124,6 @@ export const CustomerDetails: FC = () => { onClose={() => setFormDialogOpen(false)} onSubmit={handleFormSubmit} /> - - setAddUserDialogOpen(false)} - onAddUser={handleAddUser} - /> ); }; diff --git a/webui/src/pages/admin-dashboard/customers/customer-form-dialog.tsx b/webui/src/pages/admin-dashboard/customers/customer-form-dialog.tsx index dd974499a..0e91f6345 100644 --- a/webui/src/pages/admin-dashboard/customers/customer-form-dialog.tsx +++ b/webui/src/pages/admin-dashboard/customers/customer-form-dialog.tsx @@ -31,9 +31,9 @@ import { styled } from '@mui/material'; import type { SelectChangeEvent } from '@mui/material'; -import { type Customer, EnforcementState, type Tier } from "../../../extension-registry-types"; -import { MainContext } from "../../../context"; -import { handleError } from "../../../utils"; +import { type Customer, EnforcementState, type Tier } from '../../../extension-registry-types'; +import { MainContext } from '../../../context'; +import { handleError } from '../../../utils'; interface CustomerFormDialogProps { open: boolean; @@ -42,7 +42,6 @@ interface CustomerFormDialogProps { onSubmit: (formData: Customer) => Promise; } - const Code = styled('code')(({ theme }) => ({ fontFamily: 'source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace', backgroundColor: theme.palette.action.hover, // Subtle gray background diff --git a/webui/src/pages/admin-dashboard/customers/customer-member-list.tsx b/webui/src/pages/admin-dashboard/customers/customer-member-list.tsx new file mode 100644 index 000000000..4e5f54acf --- /dev/null +++ b/webui/src/pages/admin-dashboard/customers/customer-member-list.tsx @@ -0,0 +1,174 @@ +/****************************************************************************** + * 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, useEffect, useState, useContext, useRef } from 'react'; +import { + Box, + Typography, + Button, + Divider, + List, + ListItem, + ListItemAvatar, + Avatar, + ListItemText, IconButton, type PaperProps, Paper +} from '@mui/material'; +import { Link as RouterLink } from 'react-router-dom'; +import { AdminDashboardRoutes } from '../admin-routes'; +import { MainContext } from '../../../context'; +import { CustomerMembership, Customer, UserData, isError } from '../../../extension-registry-types'; +import { AddUserDialog } from '../../user/add-user-dialog'; +import DeleteIcon from '@mui/icons-material/Delete'; +import PersonAddIcon from '@mui/icons-material/PersonAdd'; +import { createRoute } from '../../../utils'; + +const sectionPaperProps: PaperProps = { elevation: 1, sx: { p: 3, mb: 3 } }; + +export const CustomerMemberList: FunctionComponent = props => { + const { service, handleError } = useContext(MainContext); + const [members, setMembers] = useState([]); + const [addDialogIsOpen, setAddDialogIsOpen] = useState(false); + const abortController = useRef(new AbortController()); + + const users = members.map(member => member.user); + + useEffect(() => { + fetchMembers(); + }, [props.customer]); + + useEffect(() => { + return () => { + abortController.current.abort(); + }; + }, []); + + const handleCloseAddDialog = async () => { + setAddDialogIsOpen(false); + fetchMembers(); + }; + + const handleOpenAddDialog = async () => { + setAddDialogIsOpen(true); + }; + + const handleAddUser = async (user: UserData) => { + try { + const result = await service.admin.addCustomerMember(abortController.current, props.customer.name, user); + if (isError(result)) { + throw result; + } + await fetchMembers(); + } catch (err) { + handleError(err); + } + }; + + const handleRemoveUser = async (user: UserData) => { + try { + const result = await service.admin.removeCustomerMember(abortController.current, props.customer.name, user); + if (isError(result)) { + throw result; + } + await fetchMembers(); + } catch (err) { + handleError(err); + } + }; + + const fetchMembers = async () => { + try { + const membershipList = await service.admin.getCustomerMembers(abortController.current, props.customer.name); + const members = membershipList.customerMemberships; + setMembers(members); + } catch (err) { + handleError(err); + } + }; + + return + + Members + + + + {members.length === 0 ? ( + + No members assigned to this customer. + + ) : ( + + {users.map(user => ( + handleRemoveUser(user)} title='Remove member'> + + + } + > + + + + + {user.loginName} + + } + secondary={user.fullName} + /> + + ))} + + )} + + + + + {/*{members.length ?*/} + {/* */} + {/* {members.map(member =>*/} + {/* changeRole(member, role)}*/} + {/* onRemoveUser={() => changeRole(member, 'remove')} />)}*/} + {/* :*/} + {/* There are no members assigned yet.}*/} + + member.user)} + onClose={handleCloseAddDialog} + onAddUser={handleAddUser} + /> + ; +}; + +export interface CustomerMemberListProps { + customer: Customer; +} \ No newline at end of file diff --git a/webui/src/pages/admin-dashboard/customers/customers.tsx b/webui/src/pages/admin-dashboard/customers/customers.tsx index b8bd12990..2ec04f58a 100644 --- a/webui/src/pages/admin-dashboard/customers/customers.tsx +++ b/webui/src/pages/admin-dashboard/customers/customers.tsx @@ -11,7 +11,7 @@ * SPDX-License-Identifier: EPL-2.0 *****************************************************************************/ -import { FC, useContext, useState, useEffect, useRef, useCallback, useMemo } from "react"; +import { FC, useContext, useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { Box, Button, @@ -21,9 +21,7 @@ import { Alert, IconButton, Stack, - Chip, - Avatar, - AvatarGroup + Chip } from "@mui/material"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import EditIcon from "@mui/icons-material/Edit"; @@ -172,25 +170,6 @@ export const Customers: FC = () => { ); } }, - { - field: 'users', - headerName: 'Members', - minWidth: 140, - sortable: false, - filterable: false, - renderCell: (params: GridRenderCellParams) => { - const users = params.row.users ?? []; - return ( - - - {users.map((user) => ( - - ))} - - - ); - } - }, { field: 'actions', headerName: 'Actions', From 7d9cc484f43fed1b0f638dc927616f85f1551824 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Wed, 1 Apr 2026 16:33:03 +0200 Subject: [PATCH 15/18] update openapi documentation wrt rate limit headers --- .../openvsx/web/DocumentationConfig.java | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/server/src/main/java/org/eclipse/openvsx/web/DocumentationConfig.java b/server/src/main/java/org/eclipse/openvsx/web/DocumentationConfig.java index 2b42379d4..e91059f0a 100644 --- a/server/src/main/java/org/eclipse/openvsx/web/DocumentationConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/web/DocumentationConfig.java @@ -75,18 +75,26 @@ public OpenApiCustomizer sortSchemasAlphabetically() { @Bean public OpenApiCustomizer addRateLimitResponse() { - var retryAfterHeader = new Header() - .description("Number of seconds to wait after receiving a 429 response") + var limitLimitHeader = new Header() + .description("Number of requests that can be made in a given amount of time") .schema(new Schema<>().type("integer").format("int32")); var limitRemainingHeader = new Header() - .description("Remaining number of requests left") + .description("Remaining number of requests left in the current time window") + .schema(new Schema<>().type("integer").format("int32")); + var limitResetHeader = new Header() + .description("Number of seconds until the rate limit tokens will be fully filled to its maximum") + .schema(new Schema<>().type("integer").format("int32")); + var retryAfterHeader = new Header() + .description("Number of seconds to wait after receiving a 429 response") .schema(new Schema<>().type("integer").format("int32")); var response = new ApiResponse() .description("A client has sent too many requests in a given amount of time") .headers(Map.of( - "X-Rate-Limit-Retry-After-Seconds", retryAfterHeader, - "X-Rate-Limit-Remaining", limitRemainingHeader + "X-RateLimit-Limit", limitLimitHeader, + "X-RateLimit-Remaining", limitRemainingHeader, + "X-RateLimit-Reset", limitResetHeader, + "Retry-After", retryAfterHeader )); return openApi -> openApi.getPaths() @@ -97,6 +105,14 @@ public OpenApiCustomizer addRateLimitResponse() { responses = new ApiResponses(); } + var okResponse = responses.get("200"); + if (okResponse == null) { + okResponse = responses.get("201"); + } + if (okResponse != null) { + okResponse.addHeaderObject("X-RateLimit-Limit", limitLimitHeader); + okResponse.addHeaderObject("X-RateLimit-Remaining", limitRemainingHeader); + } responses.addApiResponse("429", response); operation.setResponses(responses); }) From fa504b35581c2346c16c8957297e68436377bc58 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Fri, 3 Apr 2026 09:29:15 +0200 Subject: [PATCH 16/18] add default rate limit headers to all responses --- .../eclipse/openvsx/web/DocumentationConfig.java | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/server/src/main/java/org/eclipse/openvsx/web/DocumentationConfig.java b/server/src/main/java/org/eclipse/openvsx/web/DocumentationConfig.java index e91059f0a..a7ea52bc7 100644 --- a/server/src/main/java/org/eclipse/openvsx/web/DocumentationConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/web/DocumentationConfig.java @@ -101,18 +101,16 @@ public OpenApiCustomizer addRateLimitResponse() { .forEach((path, item) -> item.readOperations() .forEach(operation -> { var responses = operation.getResponses(); - if(responses == null) { + if (responses == null) { responses = new ApiResponses(); } - var okResponse = responses.get("200"); - if (okResponse == null) { - okResponse = responses.get("201"); - } - if (okResponse != null) { - okResponse.addHeaderObject("X-RateLimit-Limit", limitLimitHeader); - okResponse.addHeaderObject("X-RateLimit-Remaining", limitRemainingHeader); - } + // add default rate limit headers present in all responses + responses.forEach((status, r) -> { + r.addHeaderObject("X-RateLimit-Limit", limitLimitHeader); + r.addHeaderObject("X-RateLimit-Remaining", limitRemainingHeader); + }); + responses.addApiResponse("429", response); operation.setResponses(responses); }) From 7b664aeb95071a4b24b0ff6888271ca6a123cbc9 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Fri, 3 Apr 2026 09:29:47 +0200 Subject: [PATCH 17/18] support getting usage stats for a user assigned to a customer --- .../java/org/eclipse/openvsx/UserAPI.java | 54 +++++++++++-- .../eclipse/openvsx/entities/Customer.java | 8 ++ .../CustomerMembershipRepository.java | 2 + .../repositories/RepositoryService.java | 4 + .../usage-stats/use-usage-stats.ts | 2 +- webui/src/extension-registry-service.ts | 81 ++++--------------- .../pages/user/user-settings-customers.tsx | 3 +- webui/src/pages/user/user-settings.tsx | 2 +- 8 files changed, 78 insertions(+), 78 deletions(-) diff --git a/server/src/main/java/org/eclipse/openvsx/UserAPI.java b/server/src/main/java/org/eclipse/openvsx/UserAPI.java index 1d3ff54c2..50e5c9bf5 100644 --- a/server/src/main/java/org/eclipse/openvsx/UserAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/UserAPI.java @@ -12,19 +12,13 @@ import jakarta.servlet.http.HttpServletRequest; import org.eclipse.openvsx.accesstoken.AccessTokenService; import org.eclipse.openvsx.eclipse.EclipseService; -import org.eclipse.openvsx.entities.ExtensionVersion; -import org.eclipse.openvsx.entities.NamespaceMembership; -import org.eclipse.openvsx.entities.ScanStatus; -import org.eclipse.openvsx.entities.UserData; +import org.eclipse.openvsx.entities.*; import org.eclipse.openvsx.json.*; import org.eclipse.openvsx.repositories.ExtensionScanRepository; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.security.CodedAuthException; import org.eclipse.openvsx.storage.StorageUtilService; -import org.eclipse.openvsx.util.ErrorResultException; -import org.eclipse.openvsx.util.NamingUtil; -import org.eclipse.openvsx.util.NotFoundException; -import org.eclipse.openvsx.util.UrlUtil; +import org.eclipse.openvsx.util.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.CacheControl; @@ -484,6 +478,50 @@ public ResponseEntity setNamespaceMember(@PathVariable String namesp } } + @GetMapping( + path = "/user/customers", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public List getOwnCustomers() { + var user = users.findLoggedInUser(); + if (user == null) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } + + return repositories.findCustomerMemberships(user).map(membership -> membership.getCustomer().toUserJson() ).toList(); + } + + @GetMapping( + path = "/user/customers/{name}/usage", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity getOwnUsageStats(@PathVariable String name, @RequestParam(required = false) String date) { + try { + var user = users.findLoggedInUser(); + if (user == null) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } + + var customer = repositories.findCustomer(name); + if (customer == null) { + return ResponseEntity.notFound().build(); + } + + var membership = repositories.findCustomerMembership(user, customer); + if (membership == null) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } + + var localDateTime = date != null ? TimeUtil.fromUTCString(date) : TimeUtil.getCurrentUTC(); + var stats = repositories.findUsageStatsByCustomerAndDate(customer, localDateTime); + var result = new UsageStatsListJson(stats.stream().map(UsageStats::toJson).toList()); + return ResponseEntity.ok(result); + } catch (Exception exc) { + logger.error("failed retrieving usage stats", exc); + return ResponseEntity.internalServerError().build(); + } + } + @GetMapping( path = "/user/search/{name}", produces = MediaType.APPLICATION_JSON_VALUE diff --git a/server/src/main/java/org/eclipse/openvsx/entities/Customer.java b/server/src/main/java/org/eclipse/openvsx/entities/Customer.java index ce43dfe76..88367b409 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/Customer.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/Customer.java @@ -96,6 +96,14 @@ public CustomerJson toJson() { return json; } + public CustomerJson toUserJson() { + var json = new CustomerJson(); + json.setName(name); + json.setTier(tier.toJson()); + json.setState(""); + return json; + } + public Customer updateFromJson(CustomerJson json) { setName(json.getName()); setTier(Tier.fromJson(json.getTier())); diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/CustomerMembershipRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/CustomerMembershipRepository.java index b33a1d4a8..d8f892e24 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/CustomerMembershipRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/CustomerMembershipRepository.java @@ -20,4 +20,6 @@ public interface CustomerMembershipRepository extends Repository findByCustomer(Customer customer); CustomerMembership findByUserAndCustomer(UserData user, Customer customer); + + Streamable findByUserOrderByCustomerName(UserData user); } 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 2ebc33608..2a5a56357 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -1287,6 +1287,10 @@ public CustomerMembership findCustomerMembership(UserData user, Customer custome return customerMembershipRepo.findByUserAndCustomer(user, customer); } + public Streamable findCustomerMemberships(UserData user) { + return customerMembershipRepo.findByUserOrderByCustomerName(user); + } + public List findUsageStatsByCustomerAndDate(Customer customer, LocalDateTime date) { var startTime = date.truncatedTo(ChronoUnit.DAYS).minusMinutes(5); var endTime = date.truncatedTo(ChronoUnit.DAYS).plusDays(1); diff --git a/webui/src/components/rate-limiting/usage-stats/use-usage-stats.ts b/webui/src/components/rate-limiting/usage-stats/use-usage-stats.ts index 7b2f2d852..db6a7f41d 100644 --- a/webui/src/components/rate-limiting/usage-stats/use-usage-stats.ts +++ b/webui/src/components/rate-limiting/usage-stats/use-usage-stats.ts @@ -40,7 +40,7 @@ export const useUsageStats = (customerName: string | undefined) => { try { setLoading(true); setError(null); - const data = await service.getUsageStatsForUser( + const data = await service.getUsageStats( abortController.current, customerName, date.toJSDate() diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index 152410cda..aaf8a6d55 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -15,8 +15,7 @@ import { LoginProviders, ScanResultJson, ScanCounts, ScanResultsResponse, ScanFilterOptions, FilesResponse, FileDecisionCountsJson, ScanDecisionRequest, ScanDecisionResponse, FileDecisionRequest, FileDecisionResponse, FileDecisionDeleteRequest, FileDecisionDeleteResponse, - Tier, TierList, Customer, CustomerList, UsageStats, UsageStatsList, LogPageableList, - EnforcementState, TierType, RefillStrategy, CustomerMembershipList, + Tier, TierList, Customer, CustomerList, UsageStatsList, LogPageableList, CustomerMembershipList, } from './extension-registry-types'; import { createAbsoluteURL, addQuery } from './utils'; import { sendRequest, ErrorResponse } from './server-request'; @@ -327,73 +326,23 @@ export class ExtensionRegistryService { }); } - // TODO: Replace with real user-scoped endpoint when backend is ready - async getCustomersForUser(_abortController: AbortController): Promise[]> { - return [ - { - name: 'eclipse', - state: EnforcementState.ENFORCEMENT, - tier: { - name: 'Enterprise', - description: 'Enterprise tier with higher rate limits', - tierType: TierType.NON_FREE, - capacity: 800, - duration: 300, - refillStrategy: RefillStrategy.GREEDY, - }, - cidrBlocks: ['192.168.1.0/24', '10.0.0.0/8'], - }, - { - name: 'test', - state: EnforcementState.EVALUATION, - tier: { - name: 'Free', - tierType: TierType.FREE, - capacity: 500, - duration: 300, - refillStrategy: RefillStrategy.INTERVAL, - }, - cidrBlocks: [], - }, - ]; + async getCustomers(abortController: AbortController): Promise[]> { + return sendRequest({ + abortController, + credentials: true, + endpoint: createAbsoluteURL([this.serverUrl, 'user', 'customers']) + }); } - // TODO: Replace with real user-scoped endpoint when backend is ready - async getUsageStatsForUser(_abortController: AbortController, _customerName: string, date: Date): Promise> { - /** Generated using Gemini */ - const STEP = 5 * 60; // 5-minute windows in seconds - const dayStart = new Date(date); - dayStart.setUTCHours(0, 0, 0, 0); - const startEpoch = Math.floor(dayStart.getTime() / 1000); - - // Generate mock usage with a smooth traffic pattern (two overlapping peaks) - const stats: UsageStats[] = []; - // Simple seeded pseudo-random for deterministic but smooth noise - let seed = 42; - const rand = () => { - seed = (seed * 16807 + 0) % 2147483647; return seed / 2147483647; - }; - - for (let i = 0; i < 288; i++) { // 288 five-minute windows per day - const hour = i / 12; - // Two overlapping gaussian peaks for a more natural shape - const morning = Math.exp(-0.5 * Math.pow((hour - 10) / 2.5, 2)); - const afternoon = Math.exp(-0.5 * Math.pow((hour - 15) / 2, 2)); - const base = (morning * 500 + afternoon * 700); - // Smooth jitter: ±15% of base - const jitter = base * (rand() * 0.3 - 0.15); - const count = Math.round(Math.max(0, base + jitter)); - - if (count > 0) { - stats.push({ - windowStart: startEpoch + i * STEP, - duration: STEP, - count, - }); - } - } + async getUsageStats(abortController: AbortController, customerName: string, date: Date): Promise> { + const query: { key: string, value: string | number }[] = []; + query.push({ key: 'date', value: date.toISOString() }); - return { stats }; + return sendRequest({ + abortController, + endpoint: createAbsoluteURL([this.serverUrl, 'user', 'customers', customerName, 'usage'], query), + credentials: true + }, false); } async getNamespaceMembers(abortController: AbortController, namespace: Namespace): Promise> { diff --git a/webui/src/pages/user/user-settings-customers.tsx b/webui/src/pages/user/user-settings-customers.tsx index cf2040730..03d0b394f 100644 --- a/webui/src/pages/user/user-settings-customers.tsx +++ b/webui/src/pages/user/user-settings-customers.tsx @@ -68,8 +68,7 @@ export const UserSettingsCustomers: FunctionComponent = () => { const loadCustomers = async (): Promise => { try { - // TODO: Replace with user-scoped endpoint when backend is ready - const data = await service.getCustomersForUser(abortController.current); + const data = await service.getCustomers(abortController.current); const chosen = data.length ? data[0] : undefined; setCustomers(data); setChosenCustomer(chosen); diff --git a/webui/src/pages/user/user-settings.tsx b/webui/src/pages/user/user-settings.tsx index e3738c3c6..b3b96872e 100644 --- a/webui/src/pages/user/user-settings.tsx +++ b/webui/src/pages/user/user-settings.tsx @@ -71,7 +71,7 @@ return (log in); : null; } - return + return From 94ebfadf9aef0bb923cf3464c93f67538ad8b0fd Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Fri, 3 Apr 2026 10:23:44 +0200 Subject: [PATCH 18/18] fix unit test --- .../eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java index addcbe88a..81089fd32 100644 --- a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java +++ b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java @@ -381,6 +381,7 @@ void testExecuteQueries() { () -> repositories.countCustomersByTier(tier), () -> repositories.findAllCustomers(), () -> repositories.findCustomerMemberships(customer), + () -> repositories.findCustomerMemberships(userData), () -> repositories.findCustomerMembership(userData, customer), () -> repositories.saveUsageStats(usageStats), () -> repositories.findUsageStatsByCustomerAndDate(customer, NOW),