From 895903fe1a89d2d205bf9b6b6bf882355761dd70 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Fri, 9 Jan 2026 20:57:57 +0100 Subject: [PATCH 01/45] add customer table --- .../org/eclipse/openvsx/entities/Customer.java | 4 ++++ .../repositories/CustomerRepository.java | 18 ++++++++++++++++++ .../db/migration/V1_58__Rate_Limit.sql | 18 ++++++++++++++++++ 3 files changed, 40 insertions(+) create mode 100644 server/src/main/java/org/eclipse/openvsx/entities/Customer.java create mode 100644 server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java create mode 100644 server/src/main/resources/db/migration/V1_58__Rate_Limit.sql diff --git a/server/src/main/java/org/eclipse/openvsx/entities/Customer.java b/server/src/main/java/org/eclipse/openvsx/entities/Customer.java new file mode 100644 index 000000000..1df121777 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/entities/Customer.java @@ -0,0 +1,4 @@ +package org.eclipse.openvsx.entities; + +public class Customer { +} diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java new file mode 100644 index 000000000..d9a9f8bff --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java @@ -0,0 +1,18 @@ +/** ****************************************************************************** + * Copyright (c) 2021 Precies. Software and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + * ****************************************************************************** */ +package org.eclipse.openvsx.repositories; + +import org.eclipse.openvsx.entities.AdminStatistics; +import org.springframework.data.repository.Repository; + +public interface CustomerRepository extends Repository { + + AdminStatistics findByYearAndMonth(int year, int month); +} diff --git a/server/src/main/resources/db/migration/V1_58__Rate_Limit.sql b/server/src/main/resources/db/migration/V1_58__Rate_Limit.sql new file mode 100644 index 000000000..085f50a6c --- /dev/null +++ b/server/src/main/resources/db/migration/V1_58__Rate_Limit.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS public.download_count_processed_item (id BIGINT NOT NULL, + name CHARACTER VARYING(255) NOT NULL, + storage_type CHARACTER VARYING(32), + processed_on TIMESTAMP WITHOUT TIME ZONE, + execution_time INT, + success BOOLEAN NOT NULL +); + +ALTER TABLE ONLY public.download_count_processed_item + ADD CONSTRAINT download_count_processed_item_pkey PRIMARY KEY (id); + +CREATE INDEX IF NOT EXISTS download_count_processed_item_storage_type ON download_count_processed_item (storage_type); +CREATE INDEX IF NOT EXISTS download_count_processed_item_name ON download_count_processed_item (name); + +CREATE SEQUENCE IF NOT EXISTS download_count_processed_item_seq INCREMENT 50 OWNED BY public.download_count_processed_item.id; +SELECT SETVAL('download_count_processed_item_seq', (SELECT COALESCE(MAX(id), 1) FROM download_count_processed_item)::BIGINT); + +DROP TABLE IF EXISTS public.azure_download_count_processed_item; From 7ece19ad311591cdb779d054c9714498d381d4d5 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Fri, 9 Jan 2026 20:59:10 +0100 Subject: [PATCH 02/45] update bucket4 spring boot starter dep --- server/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/build.gradle b/server/build.gradle index e2292eb32..52cc086d1 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -34,7 +34,7 @@ def versions = [ jackson: '2.15.2', woodstox: '6.4.0', jobrunr: '7.5.0', - bucket4j: '0.12.7', + bucket4j: '0.12.10', bucket4j_redis: '8.10.1', tika: '3.2.2', bouncycastle: '1.80', From 2ba9c691fb3e0f40f5124a95e9e744a8fdc85c48 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Fri, 9 Jan 2026 20:59:45 +0100 Subject: [PATCH 03/45] add customer table --- .../eclipse/openvsx/entities/Customer.java | 48 +++++++++++++++++++ .../repositories/CustomerRepository.java | 14 ++++-- .../repositories/RepositoryService.java | 18 ++++++- .../db/migration/V1_58__Rate_Limit.sql | 21 ++------ 4 files changed, 80 insertions(+), 21 deletions(-) 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 1df121777..263941c74 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/Customer.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/Customer.java @@ -1,4 +1,52 @@ package org.eclipse.openvsx.entities; +import jakarta.persistence.*; + +import java.util.List; +import java.util.Objects; + +@Entity public class Customer { + + @Id + @GeneratedValue(generator = "customerSeq") + @SequenceGenerator(name = "customerSeq", sequenceName = "customer_seq") + private long id; + + private String name; + + @Column(length = 2048) + @Convert(converter = ListOfStringConverter.class) + private List cidrs; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public List getCidrs() { + return cidrs; + } + + public void setCidrs(List cidrs) { + this.cidrs = cidrs; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Customer that = (Customer) o; + return id == that.id + && Objects.equals(name, that.name) + && Objects.equals(cidrs, that.cidrs); + } + + @Override + public int hashCode() { + return Objects.hash(id, name, cidrs); + } } diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java index d9a9f8bff..9ecf9dada 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java @@ -1,5 +1,5 @@ /** ****************************************************************************** - * Copyright (c) 2021 Precies. Software and others + * Copyright (c) 2025 Eclipse Foundation and others * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0 which is available at @@ -9,10 +9,16 @@ * ****************************************************************************** */ package org.eclipse.openvsx.repositories; -import org.eclipse.openvsx.entities.AdminStatistics; +import org.eclipse.openvsx.entities.Customer; import org.springframework.data.repository.Repository; -public interface CustomerRepository extends Repository { +import java.util.List; - AdminStatistics findByYearAndMonth(int year, int month); +public interface CustomerRepository extends Repository { + + List findAll(); + + Customer findByNameIgnoreCase(String name); + + long count(); } 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 ac2760ee9..85fa2e237 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -60,6 +60,7 @@ public class RepositoryService { private final MigrationItemJooqRepository migrationItemJooqRepo; private final SignatureKeyPairRepository signatureKeyPairRepo; private final SignatureKeyPairJooqRepository signatureKeyPairJooqRepo; + private final CustomerRepository customerRepo; public RepositoryService( NamespaceRepository namespaceRepo, @@ -84,7 +85,8 @@ public RepositoryService( MigrationItemRepository migrationItemRepo, MigrationItemJooqRepository migrationItemJooqRepo, SignatureKeyPairRepository signatureKeyPairRepo, - SignatureKeyPairJooqRepository signatureKeyPairJooqRepo + SignatureKeyPairJooqRepository signatureKeyPairJooqRepo, + CustomerRepository customerRepo ) { this.namespaceRepo = namespaceRepo; this.namespaceJooqRepo = namespaceJooqRepo; @@ -109,6 +111,7 @@ public RepositoryService( this.migrationItemJooqRepo = migrationItemJooqRepo; this.signatureKeyPairRepo = signatureKeyPairRepo; this.signatureKeyPairJooqRepo = signatureKeyPairJooqRepo; + this.customerRepo = customerRepo; } public Namespace findNamespace(String name) { @@ -663,4 +666,17 @@ public List findRemoveFileResourceTypeResourceMigrationItems(int public boolean isDeleteAllVersions(String namespaceName, String extensionName, List targetVersions, UserData user) { return extensionVersionJooqRepo.isDeleteAllVersions(namespaceName, extensionName, targetVersions, user); } + + public List findAllCustomers() { + return customerRepo.findAll(); + } + + public Customer findCustomer(String name) { + return customerRepo.findByNameIgnoreCase(name); + } + + public long countCustomers() { + return customerRepo.count(); + } + } \ No newline at end of file diff --git a/server/src/main/resources/db/migration/V1_58__Rate_Limit.sql b/server/src/main/resources/db/migration/V1_58__Rate_Limit.sql index 085f50a6c..c99840a6a 100644 --- a/server/src/main/resources/db/migration/V1_58__Rate_Limit.sql +++ b/server/src/main/resources/db/migration/V1_58__Rate_Limit.sql @@ -1,18 +1,7 @@ -CREATE TABLE IF NOT EXISTS public.download_count_processed_item (id BIGINT NOT NULL, - name CHARACTER VARYING(255) NOT NULL, - storage_type CHARACTER VARYING(32), - processed_on TIMESTAMP WITHOUT TIME ZONE, - execution_time INT, - success BOOLEAN NOT NULL +CREATE TABLE IF NOT EXISTS public.customer (id BIGINT NOT NULL, + name CHARACTER VARYING(255) NOT NULL, + cidrs CHARACTER VARYING(2048) ); -ALTER TABLE ONLY public.download_count_processed_item - ADD CONSTRAINT download_count_processed_item_pkey PRIMARY KEY (id); - -CREATE INDEX IF NOT EXISTS download_count_processed_item_storage_type ON download_count_processed_item (storage_type); -CREATE INDEX IF NOT EXISTS download_count_processed_item_name ON download_count_processed_item (name); - -CREATE SEQUENCE IF NOT EXISTS download_count_processed_item_seq INCREMENT 50 OWNED BY public.download_count_processed_item.id; -SELECT SETVAL('download_count_processed_item_seq', (SELECT COALESCE(MAX(id), 1) FROM download_count_processed_item)::BIGINT); - -DROP TABLE IF EXISTS public.azure_download_count_processed_item; +CREATE SEQUENCE IF NOT EXISTS customer_seq INCREMENT 50 OWNED BY public.customer.id; +SELECT SETVAL('customer_seq', (SELECT COALESCE(MAX(id), 1) FROM customer)::BIGINT); From 6ba3768357f5adbdbbb0df3535f73f2e3e5dbbbe Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Tue, 13 Jan 2026 14:10:31 +0100 Subject: [PATCH 04/45] add filter impls --- .../filter/RateLimitServletFilter.java | 119 ++++++++++++++++++ .../filter/RateLimitServletFilterFactory.java | 40 ++++++ 2 files changed, 159 insertions(+) create mode 100644 server/src/main/java/org/eclipse/openvsx/ratelimit/filter/RateLimitServletFilter.java create mode 100644 server/src/main/java/org/eclipse/openvsx/ratelimit/filter/RateLimitServletFilterFactory.java diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/RateLimitServletFilter.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/RateLimitServletFilter.java new file mode 100644 index 000000000..27fb28690 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/RateLimitServletFilter.java @@ -0,0 +1,119 @@ +/* + * 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.ratelimit.filter; + +import com.giffing.bucket4j.spring.boot.starter.context.ExpressionParams; +import com.giffing.bucket4j.spring.boot.starter.context.RateLimitConditionMatchingStrategy; +import com.giffing.bucket4j.spring.boot.starter.context.RateLimitResult; +import com.giffing.bucket4j.spring.boot.starter.context.properties.FilterConfiguration; +import com.giffing.bucket4j.spring.boot.starter.filter.servlet.ServletRateLimitFilter; +import com.giffing.bucket4j.spring.boot.starter.service.RateLimitService; +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.eclipse.openvsx.cache.CacheService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.time.Instant; +import java.util.concurrent.TimeUnit; + +public class RateLimitServletFilter extends OncePerRequestFilter implements ServletRateLimitFilter { + + private final Logger logger = LoggerFactory.getLogger(RateLimitServletFilter.class); + + private CacheService cacheService; + private FilterConfiguration filterConfig; + + public RateLimitServletFilter( + CacheService cacheService, + FilterConfiguration filterConfig + ) { + this.cacheService = cacheService; + this.filterConfig = filterConfig; + } + + @Override + public void setFilterConfig(FilterConfiguration filterConfig) { + this.filterConfig = filterConfig; + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + return !request.getRequestURI().matches(filterConfig.getUrl()); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { + logger.debug("rate limit filter: {}: {}", request.getRequestURI(), request.getRemoteAddr()); + + var instant = Instant.now(); + var epochMinute = instant.getEpochSecond() / 60; + var window = epochMinute / 5 * 5; + var old = cacheService.incrementCustomerUsage(request.getRemoteAddr() + ":" + window, 1); + System.out.println(old); + + boolean allConsumed = true; + Long remainingLimit = null; + for (var rl : filterConfig.getRateLimitChecks()) { + var wrapper = rl.rateLimit(new ExpressionParams<>(request), null); + if (wrapper != null && wrapper.getRateLimitResult() != null) { + var rateLimitResult = wrapper.getRateLimitResult(); + if (rateLimitResult.isConsumed()) { + remainingLimit = RateLimitService.getRemainingLimit(remainingLimit, rateLimitResult); + } else { + allConsumed = false; + handleHttpResponseOnRateLimiting(response, rateLimitResult); + break; + } + if (filterConfig.getStrategy().equals(RateLimitConditionMatchingStrategy.FIRST)) { + break; + } + } + } + + if (allConsumed) { + if (remainingLimit != null && Boolean.FALSE.equals(filterConfig.getHideHttpResponseHeaders())) { + logger.debug("add-x-rate-limit-remaining-header;limit:{}", remainingLimit); + response.setHeader("X-Rate-Limit-Remaining", "" + remainingLimit); + } + chain.doFilter(request, response); + filterConfig.getPostRateLimitChecks() + .forEach(rlc -> { + var result = rlc.rateLimit(request, response); + if (result != null) { + logger.debug("post-rate-limit;remaining-tokens:{}", result.getRateLimitResult().getRemainingTokens()); + } + }); + } + } + + private void handleHttpResponseOnRateLimiting(HttpServletResponse httpResponse, RateLimitResult rateLimitResult) throws IOException { + httpResponse.setStatus(filterConfig.getHttpStatusCode().value()); + if (Boolean.FALSE.equals(filterConfig.getHideHttpResponseHeaders())) { + httpResponse.setHeader("X-Rate-Limit-Retry-After-Seconds", "" + TimeUnit.NANOSECONDS.toSeconds(rateLimitResult.getNanosToWaitForRefill())); + filterConfig.getHttpResponseHeaders().forEach(httpResponse::setHeader); + } + if (filterConfig.getHttpResponseBody() != null) { + httpResponse.setContentType(filterConfig.getHttpContentType()); + httpResponse.getWriter().append(filterConfig.getHttpResponseBody()); + } + } + + @Override + public int getOrder() { + return filterConfig.getOrder(); + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/RateLimitServletFilterFactory.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/RateLimitServletFilterFactory.java new file mode 100644 index 000000000..78a330fed --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/RateLimitServletFilterFactory.java @@ -0,0 +1,40 @@ +/* + * 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.ratelimit.filter; + +import com.giffing.bucket4j.spring.boot.starter.context.properties.FilterConfiguration; +import com.giffing.bucket4j.spring.boot.starter.filter.servlet.ServletRateLimitFilter; +import com.giffing.bucket4j.spring.boot.starter.filter.servlet.ServletRateLimiterFilterFactory; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.eclipse.openvsx.cache.CacheService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import redis.clients.jedis.JedisCluster; + +@Component +@ConditionalOnProperty(value = "ovsx.rate-limit.enabled", havingValue = "true") +@ConditionalOnBean(JedisCluster.class) +public class RateLimitServletFilterFactory implements ServletRateLimiterFilterFactory { + private final CacheService cacheService; + + public RateLimitServletFilterFactory(CacheService cacheService) { + this.cacheService = cacheService; + } + + @Override + public ServletRateLimitFilter create(FilterConfiguration filterConfig) { + return new RateLimitServletFilter(cacheService, filterConfig); + } +} From 2889440b247205859dea7d743a44bd37dc57e47a Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Tue, 13 Jan 2026 14:10:46 +0100 Subject: [PATCH 05/45] add copyright --- .../java/org/eclipse/openvsx/entities/Customer.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 263941c74..2062182de 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/Customer.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/Customer.java @@ -1,3 +1,15 @@ +/* + * 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.*; From b6e4e1364e5141afe48354630246056f7b64e164 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Tue, 13 Jan 2026 18:34:25 +0100 Subject: [PATCH 06/45] use jediscluster, add configuration, rename to Tieredxxx --- .../openvsx/ratelimit/UsageService.java | 43 +++++++++++++++++++ .../config/TieredRateLimitConfigBeans.java | 40 +++++++++++++++++ ...java => TieredRateLimitServletFilter.java} | 23 ++++------ ... TieredRateLimitServletFilterFactory.java} | 19 +++----- 4 files changed, 98 insertions(+), 27 deletions(-) create mode 100644 server/src/main/java/org/eclipse/openvsx/ratelimit/UsageService.java create mode 100644 server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfigBeans.java rename server/src/main/java/org/eclipse/openvsx/ratelimit/filter/{RateLimitServletFilter.java => TieredRateLimitServletFilter.java} (86%) rename server/src/main/java/org/eclipse/openvsx/ratelimit/filter/{RateLimitServletFilterFactory.java => TieredRateLimitServletFilterFactory.java} (56%) diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/UsageService.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/UsageService.java new file mode 100644 index 000000000..7d2d8b4b1 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/UsageService.java @@ -0,0 +1,43 @@ +/* + * 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.ratelimit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import redis.clients.jedis.JedisCluster; + +import java.time.Instant; + +public class UsageService { + private final static int WINDOW_MINUTES = 5; + + private final Logger logger = LoggerFactory.getLogger(UsageService.class); + + private final JedisCluster jedisCluster; + + public UsageService(JedisCluster jedisCluster) { + this.jedisCluster = jedisCluster; + } + + public void incrementUsage(String key) { + var window = getCurrentUsageWindow(); + var old = jedisCluster.hincrBy("usage", key + ":" + window, 1); + logger.info("Usage count for {}: {}", key, old + 1); + } + + private long getCurrentUsageWindow() { + var instant = Instant.now(); + var epochMinute = instant.getEpochSecond() / 60; + return epochMinute / WINDOW_MINUTES * WINDOW_MINUTES; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfigBeans.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfigBeans.java new file mode 100644 index 000000000..8807683c2 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfigBeans.java @@ -0,0 +1,40 @@ +/* + * 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.ratelimit.config; + +import com.giffing.bucket4j.spring.boot.starter.filter.servlet.ServletRateLimiterFilterFactory; +import org.eclipse.openvsx.ratelimit.UsageService; +import org.eclipse.openvsx.ratelimit.filter.TieredRateLimitServletFilterFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import redis.clients.jedis.JedisCluster; + +@Configuration +public class TieredRateLimitConfigBeans { + + @Bean + @ConditionalOnProperty(value = "ovsx.tiered-rate-limit.enabled", havingValue = "true") + @ConditionalOnBean(JedisCluster.class) + UsageService usageService(JedisCluster jedisCluster) { + return new UsageService(jedisCluster); + } + + @Bean + @ConditionalOnProperty(value = "ovsx.tiered-rate-limit.enabled", havingValue = "true") + @ConditionalOnBean(JedisCluster.class) + ServletRateLimiterFilterFactory tieredServletFilterFactory(UsageService usageService) { + return new TieredRateLimitServletFilterFactory(usageService); + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/RateLimitServletFilter.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java similarity index 86% rename from server/src/main/java/org/eclipse/openvsx/ratelimit/filter/RateLimitServletFilter.java rename to server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java index 27fb28690..6ff1afb04 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/RateLimitServletFilter.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java @@ -21,28 +21,27 @@ import jakarta.servlet.*; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.eclipse.openvsx.cache.CacheService; +import org.eclipse.openvsx.ratelimit.UsageService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; -import java.time.Instant; import java.util.concurrent.TimeUnit; -public class RateLimitServletFilter extends OncePerRequestFilter implements ServletRateLimitFilter { +public class TieredRateLimitServletFilter extends OncePerRequestFilter implements ServletRateLimitFilter { - private final Logger logger = LoggerFactory.getLogger(RateLimitServletFilter.class); + private final Logger logger = LoggerFactory.getLogger(TieredRateLimitServletFilter.class); - private CacheService cacheService; private FilterConfiguration filterConfig; + private final UsageService usageService; - public RateLimitServletFilter( - CacheService cacheService, - FilterConfiguration filterConfig + public TieredRateLimitServletFilter( + FilterConfiguration filterConfig, + UsageService usageService ) { - this.cacheService = cacheService; this.filterConfig = filterConfig; + this.usageService = usageService; } @Override @@ -59,11 +58,7 @@ protected boolean shouldNotFilter(HttpServletRequest request) throws ServletExce protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { logger.debug("rate limit filter: {}: {}", request.getRequestURI(), request.getRemoteAddr()); - var instant = Instant.now(); - var epochMinute = instant.getEpochSecond() / 60; - var window = epochMinute / 5 * 5; - var old = cacheService.incrementCustomerUsage(request.getRemoteAddr() + ":" + window, 1); - System.out.println(old); + usageService.incrementUsage(request.getRemoteAddr()); boolean allConsumed = true; Long remainingLimit = null; diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/RateLimitServletFilterFactory.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java similarity index 56% rename from server/src/main/java/org/eclipse/openvsx/ratelimit/filter/RateLimitServletFilterFactory.java rename to server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java index 78a330fed..551a1dd48 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/RateLimitServletFilterFactory.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java @@ -17,24 +17,17 @@ import com.giffing.bucket4j.spring.boot.starter.filter.servlet.ServletRateLimiterFilterFactory; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.eclipse.openvsx.cache.CacheService; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; -import redis.clients.jedis.JedisCluster; +import org.eclipse.openvsx.ratelimit.UsageService; -@Component -@ConditionalOnProperty(value = "ovsx.rate-limit.enabled", havingValue = "true") -@ConditionalOnBean(JedisCluster.class) -public class RateLimitServletFilterFactory implements ServletRateLimiterFilterFactory { - private final CacheService cacheService; +public class TieredRateLimitServletFilterFactory implements ServletRateLimiterFilterFactory { + private final UsageService usageService; - public RateLimitServletFilterFactory(CacheService cacheService) { - this.cacheService = cacheService; + public TieredRateLimitServletFilterFactory(UsageService usageService) { + this.usageService = usageService; } @Override public ServletRateLimitFilter create(FilterConfiguration filterConfig) { - return new RateLimitServletFilter(cacheService, filterConfig); + return new TieredRateLimitServletFilter(filterConfig, usageService); } } From 7e129a53d63adf5680b57eb0156432ea11001d23 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Tue, 13 Jan 2026 18:49:25 +0100 Subject: [PATCH 07/45] rename UsageService to CustomerUsageService, simplify config --- ...eService.java => CustomerUsageService.java} | 6 +++--- ...igBeans.java => TieredRateLimitConfig.java} | 18 ++++++++---------- .../filter/TieredRateLimitServletFilter.java | 10 +++++----- .../TieredRateLimitServletFilterFactory.java | 6 +++--- 4 files changed, 19 insertions(+), 21 deletions(-) rename server/src/main/java/org/eclipse/openvsx/ratelimit/{UsageService.java => CustomerUsageService.java} (86%) rename server/src/main/java/org/eclipse/openvsx/ratelimit/config/{TieredRateLimitConfigBeans.java => TieredRateLimitConfig.java} (61%) diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/UsageService.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerUsageService.java similarity index 86% rename from server/src/main/java/org/eclipse/openvsx/ratelimit/UsageService.java rename to server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerUsageService.java index 7d2d8b4b1..387829192 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/UsageService.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerUsageService.java @@ -18,14 +18,14 @@ import java.time.Instant; -public class UsageService { +public class CustomerUsageService { private final static int WINDOW_MINUTES = 5; - private final Logger logger = LoggerFactory.getLogger(UsageService.class); + private final Logger logger = LoggerFactory.getLogger(CustomerUsageService.class); private final JedisCluster jedisCluster; - public UsageService(JedisCluster jedisCluster) { + public CustomerUsageService(JedisCluster jedisCluster) { this.jedisCluster = jedisCluster; } diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfigBeans.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java similarity index 61% rename from server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfigBeans.java rename to server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java index 8807683c2..6f4194fba 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfigBeans.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java @@ -13,7 +13,7 @@ package org.eclipse.openvsx.ratelimit.config; import com.giffing.bucket4j.spring.boot.starter.filter.servlet.ServletRateLimiterFilterFactory; -import org.eclipse.openvsx.ratelimit.UsageService; +import org.eclipse.openvsx.ratelimit.CustomerUsageService; import org.eclipse.openvsx.ratelimit.filter.TieredRateLimitServletFilterFactory; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -22,19 +22,17 @@ import redis.clients.jedis.JedisCluster; @Configuration -public class TieredRateLimitConfigBeans { +@ConditionalOnProperty(value = "ovsx.tiered-rate-limit.enabled", havingValue = "true") +@ConditionalOnBean(JedisCluster.class) +public class TieredRateLimitConfig { @Bean - @ConditionalOnProperty(value = "ovsx.tiered-rate-limit.enabled", havingValue = "true") - @ConditionalOnBean(JedisCluster.class) - UsageService usageService(JedisCluster jedisCluster) { - return new UsageService(jedisCluster); + CustomerUsageService customerUsageService(JedisCluster jedisCluster) { + return new CustomerUsageService(jedisCluster); } @Bean - @ConditionalOnProperty(value = "ovsx.tiered-rate-limit.enabled", havingValue = "true") - @ConditionalOnBean(JedisCluster.class) - ServletRateLimiterFilterFactory tieredServletFilterFactory(UsageService usageService) { - return new TieredRateLimitServletFilterFactory(usageService); + ServletRateLimiterFilterFactory tieredServletFilterFactory(CustomerUsageService customerUsageService) { + return new TieredRateLimitServletFilterFactory(customerUsageService); } } diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java index 6ff1afb04..61d80c1be 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java @@ -21,7 +21,7 @@ import jakarta.servlet.*; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.eclipse.openvsx.ratelimit.UsageService; +import org.eclipse.openvsx.ratelimit.CustomerUsageService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.filter.OncePerRequestFilter; @@ -34,14 +34,14 @@ public class TieredRateLimitServletFilter extends OncePerRequestFilter implement private final Logger logger = LoggerFactory.getLogger(TieredRateLimitServletFilter.class); private FilterConfiguration filterConfig; - private final UsageService usageService; + private final CustomerUsageService customerUsageService; public TieredRateLimitServletFilter( FilterConfiguration filterConfig, - UsageService usageService + CustomerUsageService customerUsageService ) { this.filterConfig = filterConfig; - this.usageService = usageService; + this.customerUsageService = customerUsageService; } @Override @@ -58,7 +58,7 @@ protected boolean shouldNotFilter(HttpServletRequest request) throws ServletExce protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { logger.debug("rate limit filter: {}: {}", request.getRequestURI(), request.getRemoteAddr()); - usageService.incrementUsage(request.getRemoteAddr()); + customerUsageService.incrementUsage(request.getRemoteAddr()); boolean allConsumed = true; Long remainingLimit = null; diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java index 551a1dd48..c580c74c5 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java @@ -17,12 +17,12 @@ import com.giffing.bucket4j.spring.boot.starter.filter.servlet.ServletRateLimiterFilterFactory; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.eclipse.openvsx.ratelimit.UsageService; +import org.eclipse.openvsx.ratelimit.CustomerUsageService; public class TieredRateLimitServletFilterFactory implements ServletRateLimiterFilterFactory { - private final UsageService usageService; + private final CustomerUsageService usageService; - public TieredRateLimitServletFilterFactory(UsageService usageService) { + public TieredRateLimitServletFilterFactory(CustomerUsageService usageService) { this.usageService = usageService; } From fdfd109c6d4e26d447757034d738be21c8f4d2b5 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Wed, 14 Jan 2026 10:03:54 +0100 Subject: [PATCH 08/45] update mounts for docker services, add .gitignore --- .gitignore | 1 + docker-compose.yml | 14 +++++++------- 2 files changed, 8 insertions(+), 7 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..1269488f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +data diff --git a/docker-compose.yml b/docker-compose.yml index af21ec39b..ab2314151 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,7 +55,7 @@ services: command: redis-server /etc/redis.conf volumes: - ./redis.conf:/etc/redis.conf - - /redis/node-1:/data + - ./data/redis/node-1:/data environment: - REDISCLI_AUTH=openvsx healthcheck: @@ -76,7 +76,7 @@ services: command: redis-server /etc/redis.conf volumes: - ./redis.conf:/etc/redis.conf - - /redis/node-2:/data + - ./data/redis/node-2:/data environment: - REDISCLI_AUTH=openvsx healthcheck: @@ -97,7 +97,7 @@ services: command: redis-server /etc/redis.conf volumes: - ./redis.conf:/etc/redis.conf - - /redis/node-3:/data + - ./data/redis/node-3:/data environment: - REDISCLI_AUTH=openvsx healthcheck: @@ -118,7 +118,7 @@ services: command: redis-server /etc/redis.conf volumes: - ./redis.conf:/etc/redis.conf - - /redis/node-4:/data + - ./data/redis/node-4:/data environment: - REDISCLI_AUTH=openvsx healthcheck: @@ -139,7 +139,7 @@ services: command: redis-server /etc/redis.conf volumes: - ./redis.conf:/etc/redis.conf - - /redis/node-5:/data + - ./data/redis/node-5:/data environment: - REDISCLI_AUTH=openvsx healthcheck: @@ -160,7 +160,7 @@ services: command: redis-server /etc/redis.conf volumes: - ./redis.conf:/etc/redis.conf - - /redis/node-6:/data + - ./data/redis/node-6:/data environment: - REDISCLI_AUTH=openvsx healthcheck: @@ -218,7 +218,7 @@ services: profiles: - openvsx - backend - + webui: image: node:18 working_dir: /app From e91eba3242824a736d5dd70a7f891d9477650423 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Thu, 15 Jan 2026 11:06:58 +0100 Subject: [PATCH 09/45] add tier entity, update sql, add repositories, service --- .../db/migration/V1_58_1__RateLimit.sql | 3 + .../eclipse/openvsx/entities/Customer.java | 26 +++- .../entities/DurationSecondsConverter.java | 40 +++++++ .../openvsx/entities/RefillStrategy.java | 18 +++ .../org/eclipse/openvsx/entities/Tier.java | 111 ++++++++++++++++++ .../openvsx/ratelimit/CustomerService.java | 36 ++++++ .../config/TieredRateLimitConfig.java | 5 +- .../filter/TieredRateLimitServletFilter.java | 6 + .../TieredRateLimitServletFilterFactory.java | 7 +- .../repositories/CustomerRepository.java | 2 +- .../repositories/RepositoryService.java | 17 ++- .../openvsx/repositories/TierRepository.java | 24 ++++ .../db/migration/V1_58__Rate_Limit.sql | 28 +++++ 13 files changed, 315 insertions(+), 8 deletions(-) create mode 100644 server/src/dev/resources/db/migration/V1_58_1__RateLimit.sql create mode 100644 server/src/main/java/org/eclipse/openvsx/entities/DurationSecondsConverter.java create mode 100644 server/src/main/java/org/eclipse/openvsx/entities/RefillStrategy.java create mode 100644 server/src/main/java/org/eclipse/openvsx/entities/Tier.java create mode 100644 server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerService.java create mode 100644 server/src/main/java/org/eclipse/openvsx/repositories/TierRepository.java diff --git a/server/src/dev/resources/db/migration/V1_58_1__RateLimit.sql b/server/src/dev/resources/db/migration/V1_58_1__RateLimit.sql new file mode 100644 index 000000000..2f15b4124 --- /dev/null +++ b/server/src/dev/resources/db/migration/V1_58_1__RateLimit.sql @@ -0,0 +1,3 @@ +INSERT INTO tier (id, name, description, capacity, time, refill_strategy) VALUES (1, 'basic', '', 100, 60, 'GREEDY'); + +INSERT INTO customer (id, name, tier_id, cidrs) VALUES (1, 'myself', 1, '127.0.0.1/32'); 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 2062182de..39585e2a8 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/Customer.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/Customer.java @@ -14,6 +14,7 @@ import jakarta.persistence.*; +import java.util.Collections; import java.util.List; import java.util.Objects; @@ -27,9 +28,12 @@ public class Customer { private String name; + @ManyToOne + private Tier tier; + @Column(length = 2048) @Convert(converter = ListOfStringConverter.class) - private List cidrs; + private List cidrs = Collections.emptyList(); public String getName() { return name; @@ -39,6 +43,14 @@ public void setName(String name) { this.name = name; } + public Tier getTier() { + return tier; + } + + public void setTier(Tier tier) { + this.tier = tier; + } + public List getCidrs() { return cidrs; } @@ -54,11 +66,21 @@ public boolean equals(Object o) { Customer that = (Customer) o; return id == that.id && Objects.equals(name, that.name) + && Objects.equals(tier, that.tier) && Objects.equals(cidrs, that.cidrs); } @Override public int hashCode() { - return Objects.hash(id, name, cidrs); + return Objects.hash(id, name, tier, cidrs); + } + + @Override + public String toString() { + return "Customer{" + + "name='" + name + '\'' + + ", tier=" + tier + + ", cidrs=" + cidrs + + '}'; } } diff --git a/server/src/main/java/org/eclipse/openvsx/entities/DurationSecondsConverter.java b/server/src/main/java/org/eclipse/openvsx/entities/DurationSecondsConverter.java new file mode 100644 index 000000000..903a4a983 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/entities/DurationSecondsConverter.java @@ -0,0 +1,40 @@ +/* + * 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.AttributeConverter; +import jakarta.persistence.Converter; + +import java.time.Duration; + +@Converter +public class DurationSecondsConverter implements AttributeConverter { + + @Override + public Integer convertToDatabaseColumn(Duration pX) { + if (pX == null) { + return null; + } else { + return (int) pX.toSeconds(); + } + } + + @Override + public Duration convertToEntityAttribute(Integer pY) { + if (pY == null) { + return null; + } else { + return Duration.ofSeconds(pY); + } + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/entities/RefillStrategy.java b/server/src/main/java/org/eclipse/openvsx/entities/RefillStrategy.java new file mode 100644 index 000000000..bd178ecbc --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/entities/RefillStrategy.java @@ -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 + */ +package org.eclipse.openvsx.entities; + +public enum RefillStrategy { + GREEDY, + INTERVAL +} diff --git a/server/src/main/java/org/eclipse/openvsx/entities/Tier.java b/server/src/main/java/org/eclipse/openvsx/entities/Tier.java new file mode 100644 index 000000000..8a9c8a996 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/entities/Tier.java @@ -0,0 +1,111 @@ +/* + * 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 java.time.Duration; +import java.util.Objects; + +@Entity +public class Tier { + + @Id + @GeneratedValue(generator = "tierSeq") + @SequenceGenerator(name = "tierSeq", sequenceName = "tier_seq") + private long id; + + @Column(nullable = false) + private String name; + + private String description; + + @Column(nullable = false) + private int capacity; + + @Column(nullable = false) + @Convert(converter = DurationSecondsConverter.class) + private Duration time = Duration.ofMinutes(5); + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private RefillStrategy refillStrategy = RefillStrategy.GREEDY; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public int getCapacity() { + return capacity; + } + + public void setCapacity(int capacity) { + this.capacity = capacity; + } + + public Duration getTime() { + return time; + } + + public void setTime(Duration time) { + this.time = time; + } + + public RefillStrategy getRefillStrategy() { + return refillStrategy; + } + + public void setRefillStrategy(RefillStrategy refillStrategy) { + this.refillStrategy = refillStrategy; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Tier that = (Tier) o; + return id == that.id + && Objects.equals(name, that.name) + && Objects.equals(description, that.description) + && Objects.equals(capacity, that.capacity) + && Objects.equals(time, that.time) + && Objects.equals(refillStrategy, that.refillStrategy); + } + + @Override + public int hashCode() { + return Objects.hash(id, name, description, capacity, time, refillStrategy); + } + + @Override + public String toString() { + return "Tier{" + + "name='" + name + '\'' + + ", capacity=" + capacity + + ", time=" + time + + ", refillStrategy=" + refillStrategy + + '}'; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerService.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerService.java new file mode 100644 index 000000000..dc61be248 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerService.java @@ -0,0 +1,36 @@ + +/* + * 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.ratelimit; + +import org.eclipse.openvsx.entities.Customer; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +@Service +public class CustomerService { + + private final Logger logger = LoggerFactory.getLogger(CustomerService.class); + + private RepositoryService repositories; + + public CustomerService(RepositoryService repositories) { + this.repositories = repositories; + } + + public Customer getCustomer(String ip) { + return repositories.findCustomer("myself"); + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java index 6f4194fba..bf3851151 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java @@ -13,6 +13,7 @@ package org.eclipse.openvsx.ratelimit.config; import com.giffing.bucket4j.spring.boot.starter.filter.servlet.ServletRateLimiterFilterFactory; +import org.eclipse.openvsx.ratelimit.CustomerService; import org.eclipse.openvsx.ratelimit.CustomerUsageService; import org.eclipse.openvsx.ratelimit.filter.TieredRateLimitServletFilterFactory; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @@ -32,7 +33,7 @@ CustomerUsageService customerUsageService(JedisCluster jedisCluster) { } @Bean - ServletRateLimiterFilterFactory tieredServletFilterFactory(CustomerUsageService customerUsageService) { - return new TieredRateLimitServletFilterFactory(customerUsageService); + ServletRateLimiterFilterFactory tieredServletFilterFactory(CustomerService customerService, CustomerUsageService customerUsageService) { + return new TieredRateLimitServletFilterFactory(customerService, customerUsageService); } } diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java index 61d80c1be..6e54e022e 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java @@ -21,6 +21,7 @@ import jakarta.servlet.*; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.eclipse.openvsx.ratelimit.CustomerService; import org.eclipse.openvsx.ratelimit.CustomerUsageService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -34,13 +35,16 @@ public class TieredRateLimitServletFilter extends OncePerRequestFilter implement private final Logger logger = LoggerFactory.getLogger(TieredRateLimitServletFilter.class); private FilterConfiguration filterConfig; + private final CustomerService customerService; private final CustomerUsageService customerUsageService; public TieredRateLimitServletFilter( FilterConfiguration filterConfig, + CustomerService customerService, CustomerUsageService customerUsageService ) { this.filterConfig = filterConfig; + this.customerService = customerService; this.customerUsageService = customerUsageService; } @@ -58,6 +62,8 @@ protected boolean shouldNotFilter(HttpServletRequest request) throws ServletExce protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { logger.debug("rate limit filter: {}: {}", request.getRequestURI(), request.getRemoteAddr()); + var customer = customerService.getCustomer(request.getRemoteAddr()); + logger.info("handling rate limit for customer {}", customer); customerUsageService.incrementUsage(request.getRemoteAddr()); boolean allConsumed = true; diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java index c580c74c5..73d50355a 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java @@ -17,17 +17,20 @@ import com.giffing.bucket4j.spring.boot.starter.filter.servlet.ServletRateLimiterFilterFactory; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.eclipse.openvsx.ratelimit.CustomerService; import org.eclipse.openvsx.ratelimit.CustomerUsageService; public class TieredRateLimitServletFilterFactory implements ServletRateLimiterFilterFactory { + private final CustomerService customerService; private final CustomerUsageService usageService; - public TieredRateLimitServletFilterFactory(CustomerUsageService usageService) { + public TieredRateLimitServletFilterFactory(CustomerService customerService, CustomerUsageService usageService) { + this.customerService = customerService; this.usageService = usageService; } @Override public ServletRateLimitFilter create(FilterConfiguration filterConfig) { - return new TieredRateLimitServletFilter(filterConfig, usageService); + return new TieredRateLimitServletFilter(filterConfig, customerService, usageService); } } diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java index 9ecf9dada..14f4eff0d 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java @@ -1,5 +1,5 @@ /** ****************************************************************************** - * Copyright (c) 2025 Eclipse Foundation and others + * Copyright (c) 2026 Eclipse Foundation and others * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0 which is available at 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 85fa2e237..c01161ba2 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -61,6 +61,7 @@ public class RepositoryService { private final SignatureKeyPairRepository signatureKeyPairRepo; private final SignatureKeyPairJooqRepository signatureKeyPairJooqRepo; private final CustomerRepository customerRepo; + private final TierRepository tierRepo; public RepositoryService( NamespaceRepository namespaceRepo, @@ -86,7 +87,8 @@ public RepositoryService( MigrationItemJooqRepository migrationItemJooqRepo, SignatureKeyPairRepository signatureKeyPairRepo, SignatureKeyPairJooqRepository signatureKeyPairJooqRepo, - CustomerRepository customerRepo + CustomerRepository customerRepo, + TierRepository tierRepo ) { this.namespaceRepo = namespaceRepo; this.namespaceJooqRepo = namespaceJooqRepo; @@ -112,6 +114,7 @@ public RepositoryService( this.signatureKeyPairRepo = signatureKeyPairRepo; this.signatureKeyPairJooqRepo = signatureKeyPairJooqRepo; this.customerRepo = customerRepo; + this.tierRepo = tierRepo; } public Namespace findNamespace(String name) { @@ -679,4 +682,16 @@ public long countCustomers() { return customerRepo.count(); } + public List findAllTiers() { + return tierRepo.findAll(); + } + + public Tier findTier(String name) { + return tierRepo.findByNameIgnoreCase(name); + } + + public long countTiers() { + return tierRepo.count(); + } + } \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/TierRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/TierRepository.java new file mode 100644 index 000000000..d0d9cc17c --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/repositories/TierRepository.java @@ -0,0 +1,24 @@ +/** ****************************************************************************** + * Copyright (c) 2026 Eclipse Foundation and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + * ****************************************************************************** */ +package org.eclipse.openvsx.repositories; + +import org.eclipse.openvsx.entities.Tier; +import org.springframework.data.repository.Repository; + +import java.util.List; + +public interface TierRepository extends Repository { + + List findAll(); + + Tier findByNameIgnoreCase(String name); + + long count(); +} diff --git a/server/src/main/resources/db/migration/V1_58__Rate_Limit.sql b/server/src/main/resources/db/migration/V1_58__Rate_Limit.sql index c99840a6a..e9b5cb8c0 100644 --- a/server/src/main/resources/db/migration/V1_58__Rate_Limit.sql +++ b/server/src/main/resources/db/migration/V1_58__Rate_Limit.sql @@ -1,7 +1,35 @@ CREATE TABLE IF NOT EXISTS public.customer (id BIGINT NOT NULL, name CHARACTER VARYING(255) NOT NULL, + tier_id bigint, cidrs CHARACTER VARYING(2048) ); +ALTER TABLE ONLY public.customer + ADD CONSTRAINT customer_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.customer + ADD CONSTRAINT customer_unique_name UNIQUE (name); + CREATE SEQUENCE IF NOT EXISTS customer_seq INCREMENT 50 OWNED BY public.customer.id; SELECT SETVAL('customer_seq', (SELECT COALESCE(MAX(id), 1) FROM customer)::BIGINT); + + +CREATE TABLE IF NOT EXISTS public.tier (id BIGINT NOT NULL, + name CHARACTER VARYING(255) NOT NULL, + description CHARACTER VARYING(255), + capacity INTEGER NOT NULL, + time INTEGER NOT NULL, + refill_strategy CHARACTER VARYING(255) NOT NULL +); + +CREATE SEQUENCE IF NOT EXISTS tier_seq INCREMENT 50 OWNED BY public.tier.id; +SELECT SETVAL('tier_seq', (SELECT COALESCE(MAX(id), 1) FROM tier)::BIGINT); + +ALTER TABLE ONLY public.tier + ADD CONSTRAINT tier_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.tier + ADD CONSTRAINT tier_unique_name UNIQUE (name); + +ALTER TABLE ONLY public.customer + ADD CONSTRAINT customer_tier_id_fk FOREIGN KEY (tier_id) REFERENCES public.tier(id); From f4714cc0de0098574e43c482eb26743d17285d8a Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Thu, 15 Jan 2026 12:08:57 +0100 Subject: [PATCH 10/45] add usage stats entity --- .../db/migration/V1_58_1__RateLimit.sql | 3 +- .../eclipse/openvsx/entities/Customer.java | 10 ++- .../org/eclipse/openvsx/entities/Tier.java | 26 ++++--- .../eclipse/openvsx/entities/UsageStats.java | 76 +++++++++++++++++++ .../ratelimit/CollectUsageStatsJobs.java | 38 ++++++++++ .../ratelimit/CustomerUsageService.java | 28 ++++++- .../config/TieredRateLimitConfig.java | 5 +- .../repositories/RepositoryService.java | 12 ++- .../repositories/UsageStatsRepository.java | 20 +++++ .../db/migration/V1_58__Rate_Limit.sql | 55 +++++++++----- 10 files changed, 239 insertions(+), 34 deletions(-) create mode 100644 server/src/main/java/org/eclipse/openvsx/entities/UsageStats.java create mode 100644 server/src/main/java/org/eclipse/openvsx/ratelimit/CollectUsageStatsJobs.java create mode 100644 server/src/main/java/org/eclipse/openvsx/repositories/UsageStatsRepository.java diff --git a/server/src/dev/resources/db/migration/V1_58_1__RateLimit.sql b/server/src/dev/resources/db/migration/V1_58_1__RateLimit.sql index 2f15b4124..008474762 100644 --- a/server/src/dev/resources/db/migration/V1_58_1__RateLimit.sql +++ b/server/src/dev/resources/db/migration/V1_58_1__RateLimit.sql @@ -1,3 +1,4 @@ -INSERT INTO tier (id, name, description, capacity, time, refill_strategy) VALUES (1, 'basic', '', 100, 60, 'GREEDY'); +-- add basic tier and assign customer for loopback IP to it +INSERT INTO tier (id, name, description, capacity, duration, refill_strategy) VALUES (1, 'basic', '', 100, 60, 'GREEDY'); INSERT INTO customer (id, name, tier_id, cidrs) VALUES (1, 'myself', 1, '127.0.0.1/32'); 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 39585e2a8..08a70398b 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/Customer.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/Customer.java @@ -14,12 +14,20 @@ import jakarta.persistence.*; +import java.io.Serial; +import java.io.Serializable; import java.util.Collections; import java.util.List; import java.util.Objects; @Entity -public class Customer { +@Table(uniqueConstraints = { + @UniqueConstraint(columnNames = { "name" }), +}) +public class Customer implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; @Id @GeneratedValue(generator = "customerSeq") diff --git a/server/src/main/java/org/eclipse/openvsx/entities/Tier.java b/server/src/main/java/org/eclipse/openvsx/entities/Tier.java index 8a9c8a996..bae6d51c1 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/Tier.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/Tier.java @@ -14,11 +14,19 @@ import jakarta.persistence.*; +import java.io.Serial; +import java.io.Serializable; import java.time.Duration; import java.util.Objects; @Entity -public class Tier { +@Table(uniqueConstraints = { + @UniqueConstraint(columnNames = { "name" }), +}) +public class Tier implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; @Id @GeneratedValue(generator = "tierSeq") @@ -35,7 +43,7 @@ public class Tier { @Column(nullable = false) @Convert(converter = DurationSecondsConverter.class) - private Duration time = Duration.ofMinutes(5); + private Duration duration = Duration.ofMinutes(5); @Column(nullable = false) @Enumerated(EnumType.STRING) @@ -65,12 +73,12 @@ public void setCapacity(int capacity) { this.capacity = capacity; } - public Duration getTime() { - return time; + public Duration getDuration() { + return duration; } - public void setTime(Duration time) { - this.time = time; + public void setDuration(Duration duration) { + this.duration = duration; } public RefillStrategy getRefillStrategy() { @@ -90,13 +98,13 @@ public boolean equals(Object o) { && Objects.equals(name, that.name) && Objects.equals(description, that.description) && Objects.equals(capacity, that.capacity) - && Objects.equals(time, that.time) + && Objects.equals(duration, that.duration) && Objects.equals(refillStrategy, that.refillStrategy); } @Override public int hashCode() { - return Objects.hash(id, name, description, capacity, time, refillStrategy); + return Objects.hash(id, name, description, capacity, duration, refillStrategy); } @Override @@ -104,7 +112,7 @@ public String toString() { return "Tier{" + "name='" + name + '\'' + ", capacity=" + capacity + - ", time=" + time + + ", duration=" + duration + ", refillStrategy=" + refillStrategy + '}'; } diff --git a/server/src/main/java/org/eclipse/openvsx/entities/UsageStats.java b/server/src/main/java/org/eclipse/openvsx/entities/UsageStats.java new file mode 100644 index 000000000..62a851414 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/entities/UsageStats.java @@ -0,0 +1,76 @@ +/* + * 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 java.io.Serial; +import java.io.Serializable; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; + +@Entity +@Table(uniqueConstraints = { + @UniqueConstraint(columnNames = { "customer_id", "windowStart" }), +}) +public class UsageStats implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(generator = "usageStatsSeq") + @SequenceGenerator(name = "usageStatsSeq", sequenceName = "usage_stats_seq") + private long id; + + @ManyToOne + private Customer customer; + + @Column(nullable = false) + private Instant windowStart; + + @Column(nullable = false) + @Convert(converter = DurationSecondsConverter.class) + private Duration duration; + + @Column(nullable = false) + private long count = 0; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + UsageStats that = (UsageStats) o; + return id == that.id + && Objects.equals(customer, that.customer) + && Objects.equals(windowStart, that.windowStart) + && Objects.equals(duration, that.duration) + && Objects.equals(count, that.count); + } + + @Override + public int hashCode() { + return Objects.hash(id, customer, windowStart, duration, count); + } + + @Override + public String toString() { + return "UsageStats{" + + "customer='" + customer.getName() + '\'' + + ", windowStart=" + windowStart + + ", duration=" + duration + + ", count=" + count + + '}'; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/CollectUsageStatsJobs.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/CollectUsageStatsJobs.java new file mode 100644 index 000000000..be3092286 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/CollectUsageStatsJobs.java @@ -0,0 +1,38 @@ +/* + * 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.ratelimit; + +import org.jobrunr.jobs.annotations.Job; +import org.jobrunr.jobs.annotations.Recurring; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +@Component +public class CollectUsageStatsJobs { + private final Logger logger = LoggerFactory.getLogger(CollectUsageStatsJobs.class); + + private CustomerUsageService customerUsageService; + + public CollectUsageStatsJobs(CustomerUsageService customerUsageService) { + this.customerUsageService = customerUsageService; + } + + @Job(name = "Collect usage stats", retries = 0) + @Recurring(id = "collect-usage-stats", cron = "*/15 * * * * *", zoneId = "UTC") + public void collect() { + logger.info("starting collect usage stats job"); + + customerUsageService.persistUsageStats(); + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerUsageService.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerUsageService.java index 387829192..0ca0c238d 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerUsageService.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerUsageService.java @@ -12,29 +12,53 @@ */ package org.eclipse.openvsx.ratelimit; +import org.eclipse.openvsx.repositories.RepositoryService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import redis.clients.jedis.JedisCluster; +import redis.clients.jedis.params.ScanParams; +import redis.clients.jedis.resps.ScanResult; import java.time.Instant; +import java.util.Map; public class CustomerUsageService { + private final static String USAGE_KEY = "usage"; private final static int WINDOW_MINUTES = 5; private final Logger logger = LoggerFactory.getLogger(CustomerUsageService.class); + private final RepositoryService repositories; private final JedisCluster jedisCluster; - public CustomerUsageService(JedisCluster jedisCluster) { + public CustomerUsageService(RepositoryService repositories, JedisCluster jedisCluster) { + this.repositories = repositories; this.jedisCluster = jedisCluster; } public void incrementUsage(String key) { var window = getCurrentUsageWindow(); - var old = jedisCluster.hincrBy("usage", key + ":" + window, 1); + var old = jedisCluster.hincrBy(USAGE_KEY, key + ":" + window, 1); logger.info("Usage count for {}: {}", key, old + 1); } + public void persistUsageStats() { + var window = getCurrentUsageWindow(); + + String cursor = ScanParams.SCAN_POINTER_START; + ScanResult> results; + + do { + results = jedisCluster.hscan(USAGE_KEY, cursor); + + for (var result : results.getResult()) { + logger.info("{} - {}", result.getKey(), result.getValue()); + } + + cursor = results.getCursor(); + } while (!results.isCompleteIteration()); + } + private long getCurrentUsageWindow() { var instant = Instant.now(); var epochMinute = instant.getEpochSecond() / 60; diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java index bf3851151..c3729e4b5 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java @@ -16,6 +16,7 @@ import org.eclipse.openvsx.ratelimit.CustomerService; import org.eclipse.openvsx.ratelimit.CustomerUsageService; import org.eclipse.openvsx.ratelimit.filter.TieredRateLimitServletFilterFactory; +import org.eclipse.openvsx.repositories.RepositoryService; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; @@ -28,8 +29,8 @@ public class TieredRateLimitConfig { @Bean - CustomerUsageService customerUsageService(JedisCluster jedisCluster) { - return new CustomerUsageService(jedisCluster); + CustomerUsageService customerUsageService(RepositoryService repositories, JedisCluster jedisCluster) { + return new CustomerUsageService(repositories, jedisCluster); } @Bean 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 c01161ba2..2272292af 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -9,6 +9,7 @@ ********************************************************************************/ package org.eclipse.openvsx.repositories; +import jakarta.transaction.Transactional; import org.eclipse.openvsx.entities.*; import org.eclipse.openvsx.json.QueryRequest; import org.eclipse.openvsx.json.TargetPlatformVersionJson; @@ -62,6 +63,7 @@ public class RepositoryService { private final SignatureKeyPairJooqRepository signatureKeyPairJooqRepo; private final CustomerRepository customerRepo; private final TierRepository tierRepo; + private final UsageStatsRepository usageStatsRepository; public RepositoryService( NamespaceRepository namespaceRepo, @@ -88,7 +90,8 @@ public RepositoryService( SignatureKeyPairRepository signatureKeyPairRepo, SignatureKeyPairJooqRepository signatureKeyPairJooqRepo, CustomerRepository customerRepo, - TierRepository tierRepo + TierRepository tierRepo, + UsageStatsRepository usageStatsRepository ) { this.namespaceRepo = namespaceRepo; this.namespaceJooqRepo = namespaceJooqRepo; @@ -115,6 +118,7 @@ public RepositoryService( this.signatureKeyPairJooqRepo = signatureKeyPairJooqRepo; this.customerRepo = customerRepo; this.tierRepo = tierRepo; + this.usageStatsRepository = usageStatsRepository; } public Namespace findNamespace(String name) { @@ -694,4 +698,8 @@ public long countTiers() { return tierRepo.count(); } -} \ No newline at end of file + @Transactional + public void saveUsageStats(UsageStats usageStats) { + usageStatsRepository.save(usageStats); + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/UsageStatsRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/UsageStatsRepository.java new file mode 100644 index 000000000..dac14599b --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/repositories/UsageStatsRepository.java @@ -0,0 +1,20 @@ +/* + * 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.UsageStats; +import org.springframework.data.repository.CrudRepository; + +public interface UsageStatsRepository extends CrudRepository { + +} diff --git a/server/src/main/resources/db/migration/V1_58__Rate_Limit.sql b/server/src/main/resources/db/migration/V1_58__Rate_Limit.sql index e9b5cb8c0..8320e581d 100644 --- a/server/src/main/resources/db/migration/V1_58__Rate_Limit.sql +++ b/server/src/main/resources/db/migration/V1_58__Rate_Limit.sql @@ -1,24 +1,9 @@ -CREATE TABLE IF NOT EXISTS public.customer (id BIGINT NOT NULL, - name CHARACTER VARYING(255) NOT NULL, - tier_id bigint, - cidrs CHARACTER VARYING(2048) -); - -ALTER TABLE ONLY public.customer - ADD CONSTRAINT customer_pkey PRIMARY KEY (id); - -ALTER TABLE ONLY public.customer - ADD CONSTRAINT customer_unique_name UNIQUE (name); - -CREATE SEQUENCE IF NOT EXISTS customer_seq INCREMENT 50 OWNED BY public.customer.id; -SELECT SETVAL('customer_seq', (SELECT COALESCE(MAX(id), 1) FROM customer)::BIGINT); - - +-- create tier table CREATE TABLE IF NOT EXISTS public.tier (id BIGINT NOT NULL, name CHARACTER VARYING(255) NOT NULL, description CHARACTER VARYING(255), capacity INTEGER NOT NULL, - time INTEGER NOT NULL, + duration INTEGER NOT NULL, refill_strategy CHARACTER VARYING(255) NOT NULL ); @@ -31,5 +16,41 @@ ALTER TABLE ONLY public.tier ALTER TABLE ONLY public.tier ADD CONSTRAINT tier_unique_name UNIQUE (name); +-- create customer table +CREATE TABLE IF NOT EXISTS public.customer (id BIGINT NOT NULL, + name CHARACTER VARYING(255) NOT NULL, + tier_id bigint, + cidrs CHARACTER VARYING(2048) + ); + +ALTER TABLE ONLY public.customer + ADD CONSTRAINT customer_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.customer + ADD CONSTRAINT customer_unique_name UNIQUE (name); + ALTER TABLE ONLY public.customer ADD CONSTRAINT customer_tier_id_fk FOREIGN KEY (tier_id) REFERENCES public.tier(id); + +CREATE SEQUENCE IF NOT EXISTS customer_seq INCREMENT 50 OWNED BY public.customer.id; +SELECT SETVAL('customer_seq', (SELECT COALESCE(MAX(id), 1) FROM customer)::BIGINT); + +-- create usage_stats table +CREATE TABLE IF NOT EXISTS public.usage_stats (id BIGINT NOT NULL, + customer_id BIGINT, + window_start BIGINT NOT NULL, + duration INTEGER NOT NULL, + count BIGINT NOT NULL +); + +ALTER TABLE ONLY public.usage_stats + ADD CONSTRAINT usage_stats_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.usage_stats + ADD CONSTRAINT usage_stats_unique_customer_window UNIQUE (customer_id, window_start); + +ALTER TABLE ONLY public.usage_stats + ADD CONSTRAINT usage_stats_customer_id_fk FOREIGN KEY (customer_id) REFERENCES public.customer(id); + +CREATE SEQUENCE IF NOT EXISTS usage_stats_seq INCREMENT 50 OWNED BY public.usage_stats.id; +SELECT SETVAL('usage_stats_seq', (SELECT COALESCE(MAX(id), 1) FROM usage_stats)::BIGINT); From 60cc298b37a4f465f6e2f0b820d74edfcb529a24 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Mon, 19 Jan 2026 11:25:37 +0100 Subject: [PATCH 11/45] add IdentittyService, CollectUsageStatsJobs --- server/build.gradle | 4 +- .../db/migration/V1_58_1__RateLimit.sql | 2 +- .../eclipse/openvsx/cache/CacheConfig.java | 2 + .../eclipse/openvsx/cache/CacheService.java | 21 ++-- .../eclipse/openvsx/entities/Customer.java | 24 +++-- .../eclipse/openvsx/entities/UsageStats.java | 44 ++++++++- .../ratelimit/CollectUsageStatsJobs.java | 9 +- .../openvsx/ratelimit/CustomerService.java | 37 ++++++- .../ratelimit/CustomerUsageService.java | 67 ------------- .../openvsx/ratelimit/IdentityService.java | 47 +++++++++ .../openvsx/ratelimit/ResolvedIdentity.java | 43 ++++++++ .../openvsx/ratelimit/UsageDataService.java | 99 +++++++++++++++++++ .../config/TieredRateLimitConfig.java | 71 ++++++++++++- .../filter/TieredRateLimitServletFilter.java | 24 +++-- .../TieredRateLimitServletFilterFactory.java | 17 ++-- .../repositories/CustomerRepository.java | 5 +- .../repositories/RepositoryService.java | 7 +- .../db/migration/V1_58__Rate_Limit.sql | 8 +- 18 files changed, 404 insertions(+), 127 deletions(-) delete mode 100644 server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerUsageService.java create mode 100644 server/src/main/java/org/eclipse/openvsx/ratelimit/IdentityService.java create mode 100644 server/src/main/java/org/eclipse/openvsx/ratelimit/ResolvedIdentity.java create mode 100644 server/src/main/java/org/eclipse/openvsx/ratelimit/UsageDataService.java diff --git a/server/build.gradle b/server/build.gradle index 52cc086d1..104fc7c08 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -43,7 +43,8 @@ def versions = [ jaxb_impl: '2.3.8', gatling: '3.14.9', loki4j: '1.4.2', - jedis: '6.2.0' + jedis: '6.2.0', + ipaddress: '5.5.1' ] ext['junit-jupiter.version'] = versions.junit java { @@ -117,6 +118,7 @@ dependencies { implementation "redis.clients:jedis:${versions.jedis}" implementation "com.giffing.bucket4j.spring.boot.starter:bucket4j-spring-boot-starter:${versions.bucket4j}" implementation "com.bucket4j:bucket4j-redis:${versions.bucket4j_redis}" + implementation "com.github.seancfoley:ipaddress:${versions.ipaddress}" implementation "org.jobrunr:jobrunr-spring-boot-3-starter:${versions.jobrunr}" implementation "org.flywaydb:flyway-core:${versions.flyway}" implementation "com.google.cloud:google-cloud-storage:${versions.gcloud}" diff --git a/server/src/dev/resources/db/migration/V1_58_1__RateLimit.sql b/server/src/dev/resources/db/migration/V1_58_1__RateLimit.sql index 008474762..b5f31bf37 100644 --- a/server/src/dev/resources/db/migration/V1_58_1__RateLimit.sql +++ b/server/src/dev/resources/db/migration/V1_58_1__RateLimit.sql @@ -1,4 +1,4 @@ -- add basic tier and assign customer for loopback IP to it INSERT INTO tier (id, name, description, capacity, duration, refill_strategy) VALUES (1, 'basic', '', 100, 60, 'GREEDY'); -INSERT INTO customer (id, name, tier_id, cidrs) VALUES (1, 'myself', 1, '127.0.0.1/32'); +INSERT INTO customer (id, name, tier_id, cidr_blocks) VALUES (1, 'loopback', 1, '127.0.0.1/32'); diff --git a/server/src/main/java/org/eclipse/openvsx/cache/CacheConfig.java b/server/src/main/java/org/eclipse/openvsx/cache/CacheConfig.java index 777d0527a..9a8a40984 100644 --- a/server/src/main/java/org/eclipse/openvsx/cache/CacheConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/cache/CacheConfig.java @@ -25,6 +25,7 @@ import org.eclipse.openvsx.search.SearchResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -111,6 +112,7 @@ public CacheManager fileCacheManager( caffeineCacheManager.registerCustomCache(CACHE_EXTENSION_FILES, extensionCache); caffeineCacheManager.registerCustomCache(CACHE_WEB_RESOURCE_FILES, webResourceCache); caffeineCacheManager.registerCustomCache(CACHE_BROWSE_EXTENSION_FILES, browseCache); + return caffeineCacheManager; } diff --git a/server/src/main/java/org/eclipse/openvsx/cache/CacheService.java b/server/src/main/java/org/eclipse/openvsx/cache/CacheService.java index a44149a92..06b805f62 100644 --- a/server/src/main/java/org/eclipse/openvsx/cache/CacheService.java +++ b/server/src/main/java/org/eclipse/openvsx/cache/CacheService.java @@ -14,6 +14,7 @@ import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.util.TargetPlatform; import org.eclipse.openvsx.util.VersionAlias; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.cache.CacheManager; import org.springframework.data.redis.cache.RedisCacheWriter; import org.springframework.stereotype.Component; @@ -39,20 +40,20 @@ public class CacheService { public static final String GENERATOR_LATEST_EXTENSION_VERSION = "latestExtensionVersionCacheKeyGenerator"; public static final String GENERATOR_FILES = "filesCacheKeyGenerator"; - private final CacheManager cacheManager; + private final CacheManager fileCacheManager; private final RepositoryService repositories; private final ExtensionJsonCacheKeyGenerator extensionJsonCacheKey; private final LatestExtensionVersionCacheKeyGenerator latestExtensionVersionCacheKey; private final FilesCacheKeyGenerator filesCacheKeyGenerator; public CacheService( - CacheManager cacheManager, + CacheManager fileCacheManager, RepositoryService repositories, ExtensionJsonCacheKeyGenerator extensionJsonCacheKey, LatestExtensionVersionCacheKeyGenerator latestExtensionVersionCacheKey, FilesCacheKeyGenerator filesCacheKeyGenerator ) { - this.cacheManager = cacheManager; + this.fileCacheManager = fileCacheManager; this.repositories = repositories; this.extensionJsonCacheKey = extensionJsonCacheKey; this.latestExtensionVersionCacheKey = latestExtensionVersionCacheKey; @@ -76,7 +77,7 @@ public void evictNamespaceDetails(Extension extension) { } private void evictNamespaceDetails(String namespaceName) { - var cache = cacheManager.getCache(CACHE_NAMESPACE_DETAILS_JSON); + var cache = fileCacheManager.getCache(CACHE_NAMESPACE_DETAILS_JSON); if(cache == null) { return; // cache is not created } @@ -93,7 +94,7 @@ public void evictExtensionJsons(UserData user) { } public void evictExtensionJsons(Extension extension) { - var cache = cacheManager.getCache(CACHE_EXTENSION_JSON); + var cache = fileCacheManager.getCache(CACHE_EXTENSION_JSON); if (cache == null) { return; // cache is not created } @@ -126,7 +127,7 @@ public void evictExtensionJsons(Extension extension) { } public void evictExtensionJsons(ExtensionVersion extVersion) { - var cache = cacheManager.getCache(CACHE_EXTENSION_JSON); + var cache = fileCacheManager.getCache(CACHE_EXTENSION_JSON); if (cache == null) { return; // cache is not created } @@ -150,7 +151,7 @@ public void evictLatestExtensionVersions() { } public void evictLatestExtensionVersion(Extension extension) { - var cache = cacheManager.getCache(CACHE_LATEST_EXTENSION_VERSION); + var cache = fileCacheManager.getCache(CACHE_LATEST_EXTENSION_VERSION); if(cache == null) { return; } @@ -178,7 +179,7 @@ public void evictLatestExtensionVersion(Extension extension) { } private void invalidateCache(String cacheName) { - var cache = cacheManager.getCache(cacheName); + var cache = fileCacheManager.getCache(cacheName); if(cache == null) { return; } @@ -187,7 +188,7 @@ private void invalidateCache(String cacheName) { } public void evictExtensionFile(FileResource download) { - var cache = cacheManager.getCache(CACHE_EXTENSION_FILES); + var cache = fileCacheManager.getCache(CACHE_EXTENSION_FILES); if(cache == null) { return; } @@ -197,7 +198,7 @@ public void evictExtensionFile(FileResource download) { @Observed public void evictWebResourceFile(String namespaceName, String extensionName, String targetPlatform, String version, String path) { - var cache = cacheManager.getCache(CACHE_WEB_RESOURCE_FILES); + var cache = fileCacheManager.getCache(CACHE_WEB_RESOURCE_FILES); if(cache == null) { return; } 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 08a70398b..f629b455b 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/Customer.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/Customer.java @@ -41,7 +41,15 @@ public class Customer implements Serializable { @Column(length = 2048) @Convert(converter = ListOfStringConverter.class) - private List cidrs = Collections.emptyList(); + private List cidrBlocks = Collections.emptyList(); + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } public String getName() { return name; @@ -59,12 +67,12 @@ public void setTier(Tier tier) { this.tier = tier; } - public List getCidrs() { - return cidrs; + public List getCidrBlocks() { + return cidrBlocks; } - public void setCidrs(List cidrs) { - this.cidrs = cidrs; + public void setCidrs(List cidrBlocks) { + this.cidrBlocks = cidrBlocks; } @Override @@ -75,12 +83,12 @@ public boolean equals(Object o) { return id == that.id && Objects.equals(name, that.name) && Objects.equals(tier, that.tier) - && Objects.equals(cidrs, that.cidrs); + && Objects.equals(cidrBlocks, that.cidrBlocks); } @Override public int hashCode() { - return Objects.hash(id, name, tier, cidrs); + return Objects.hash(id, name, tier, cidrBlocks); } @Override @@ -88,7 +96,7 @@ public String toString() { return "Customer{" + "name='" + name + '\'' + ", tier=" + tier + - ", cidrs=" + cidrs + + ", cidrBlocks=" + cidrBlocks + '}'; } } diff --git a/server/src/main/java/org/eclipse/openvsx/entities/UsageStats.java b/server/src/main/java/org/eclipse/openvsx/entities/UsageStats.java index 62a851414..07250e2e9 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/UsageStats.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/UsageStats.java @@ -17,7 +17,7 @@ import java.io.Serial; import java.io.Serializable; import java.time.Duration; -import java.time.Instant; +import java.time.LocalDateTime; import java.util.Objects; @Entity @@ -38,7 +38,7 @@ public class UsageStats implements Serializable { private Customer customer; @Column(nullable = false) - private Instant windowStart; + private LocalDateTime windowStart; @Column(nullable = false) @Convert(converter = DurationSecondsConverter.class) @@ -47,6 +47,46 @@ public class UsageStats implements Serializable { @Column(nullable = false) private long count = 0; + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public Customer getCustomer() { + return customer; + } + + public void setCustomer(Customer customer) { + this.customer = customer; + } + + public LocalDateTime getWindowStart() { + return windowStart; + } + + public void setWindowStart(LocalDateTime windowStart) { + this.windowStart = windowStart; + } + + public Duration getDuration() { + return duration; + } + + public void setDuration(Duration duration) { + this.duration = duration; + } + + public long getCount() { + return count; + } + + public void setCount(long count) { + this.count = count; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/CollectUsageStatsJobs.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/CollectUsageStatsJobs.java index be3092286..4fa6177bc 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/CollectUsageStatsJobs.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/CollectUsageStatsJobs.java @@ -22,17 +22,16 @@ public class CollectUsageStatsJobs { private final Logger logger = LoggerFactory.getLogger(CollectUsageStatsJobs.class); - private CustomerUsageService customerUsageService; + private final UsageDataService usageDataService; - public CollectUsageStatsJobs(CustomerUsageService customerUsageService) { - this.customerUsageService = customerUsageService; + public CollectUsageStatsJobs(UsageDataService usageDataService) { + this.usageDataService = usageDataService; } @Job(name = "Collect usage stats", retries = 0) @Recurring(id = "collect-usage-stats", cron = "*/15 * * * * *", zoneId = "UTC") public void collect() { logger.info("starting collect usage stats job"); - - customerUsageService.persistUsageStats(); + usageDataService.persistUsageStats(); } } 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 dc61be248..9cab66078 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerService.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerService.java @@ -13,24 +13,51 @@ */ package org.eclipse.openvsx.ratelimit; +import inet.ipaddr.IPAddressString; import org.eclipse.openvsx.entities.Customer; import org.eclipse.openvsx.repositories.RepositoryService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Service; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Component; -@Service +import java.util.Optional; + +import static org.eclipse.openvsx.ratelimit.config.TieredRateLimitConfig.CACHE_RATE_LIMIT_CUSTOMER; + +@Component public class CustomerService { private final Logger logger = LoggerFactory.getLogger(CustomerService.class); - private RepositoryService repositories; + private final RepositoryService repositories; public CustomerService(RepositoryService repositories) { this.repositories = repositories; } - public Customer getCustomer(String ip) { - return repositories.findCustomer("myself"); + @Cacheable(value = CACHE_RATE_LIMIT_CUSTOMER, key = "'id_' + #id", cacheManager = "rateLimitCacheManager") + public Optional getCustomerById(long id) { + return repositories.findCustomerById(id); + } + + @Cacheable(value = CACHE_RATE_LIMIT_CUSTOMER, cacheManager = "rateLimitCacheManager") + public Optional getCustomerByIpAddress(String ipAddress) { + for (Customer customer : repositories.findAllCustomers()) { + for (String cidrBlock : customer.getCidrBlocks()) { + if (containsIP(cidrBlock, ipAddress)) { + return Optional.of(customer); + } + } + } + + return Optional.empty(); + } + + private boolean containsIP(String cidrBlock, String ipAddress) { + var block = new IPAddressString(cidrBlock); + var ip = new IPAddressString(ipAddress); + + return block.contains(ip); } } diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerUsageService.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerUsageService.java deleted file mode 100644 index 0ca0c238d..000000000 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerUsageService.java +++ /dev/null @@ -1,67 +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 - */ -package org.eclipse.openvsx.ratelimit; - -import org.eclipse.openvsx.repositories.RepositoryService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import redis.clients.jedis.JedisCluster; -import redis.clients.jedis.params.ScanParams; -import redis.clients.jedis.resps.ScanResult; - -import java.time.Instant; -import java.util.Map; - -public class CustomerUsageService { - private final static String USAGE_KEY = "usage"; - private final static int WINDOW_MINUTES = 5; - - private final Logger logger = LoggerFactory.getLogger(CustomerUsageService.class); - - private final RepositoryService repositories; - private final JedisCluster jedisCluster; - - public CustomerUsageService(RepositoryService repositories, JedisCluster jedisCluster) { - this.repositories = repositories; - this.jedisCluster = jedisCluster; - } - - public void incrementUsage(String key) { - var window = getCurrentUsageWindow(); - var old = jedisCluster.hincrBy(USAGE_KEY, key + ":" + window, 1); - logger.info("Usage count for {}: {}", key, old + 1); - } - - public void persistUsageStats() { - var window = getCurrentUsageWindow(); - - String cursor = ScanParams.SCAN_POINTER_START; - ScanResult> results; - - do { - results = jedisCluster.hscan(USAGE_KEY, cursor); - - for (var result : results.getResult()) { - logger.info("{} - {}", result.getKey(), result.getValue()); - } - - cursor = results.getCursor(); - } while (!results.isCompleteIteration()); - } - - private long getCurrentUsageWindow() { - var instant = Instant.now(); - var epochMinute = instant.getEpochSecond() / 60; - return epochMinute / WINDOW_MINUTES * WINDOW_MINUTES; - } -} diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/IdentityService.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/IdentityService.java new file mode 100644 index 000000000..b78466df4 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/IdentityService.java @@ -0,0 +1,47 @@ +/* + * 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.ratelimit; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Component; + +@Component +public class IdentityService { + + private final CustomerService customerService; + + public IdentityService(CustomerService customerService) { + this.customerService = customerService; + } + + public ResolvedIdentity resolveIdentity(HttpServletRequest request) { + var forwardedFor = request.getHeader("X-Forwarded-For"); + var ipAddress = forwardedFor != null ? forwardedFor : request.getRemoteAddr(); + + var token = request.getParameter("token"); + if (token != null) { + + } + + var session = request.getSession(false); + var sessionId = session != null ? session.getId() : null; + + var customer = customerService.getCustomerByIpAddress(ipAddress); + if (customer.isPresent()) { + return ResolvedIdentity.ofCustomer(customer.get()); + } + + + return ResolvedIdentity.anonymous(); + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/ResolvedIdentity.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/ResolvedIdentity.java new file mode 100644 index 000000000..3783a3c1f --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/ResolvedIdentity.java @@ -0,0 +1,43 @@ +/* + * 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.ratelimit; + +import jakarta.validation.constraints.NotNull; +import org.eclipse.openvsx.entities.Customer; +import org.eclipse.openvsx.entities.UserData; + +import javax.annotation.Nullable; + +public record ResolvedIdentity(@Nullable Customer customer) { + + public boolean isCustomer() { + return customer != null; + } + + public @NotNull Customer getCustomer() { + if (isCustomer()) { + return customer; + } else { + throw new RuntimeException("no customer associated to identity"); + } + } + + public static ResolvedIdentity anonymous() { + return new ResolvedIdentity(null); + } + + public static ResolvedIdentity ofCustomer(Customer customer) { + return new ResolvedIdentity(customer); + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/UsageDataService.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/UsageDataService.java new file mode 100644 index 000000000..1e3e3e4c3 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/UsageDataService.java @@ -0,0 +1,99 @@ +/* + * 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.ratelimit; + +import org.eclipse.openvsx.entities.Customer; +import org.eclipse.openvsx.entities.UsageStats; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import redis.clients.jedis.JedisCluster; +import redis.clients.jedis.params.ScanParams; +import redis.clients.jedis.resps.ScanResult; + +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Map; + +public class UsageDataService { + private final static String USAGE_DATA_KEY = "usage.customer"; + private final static int WINDOW_MINUTES = 5; + + private final Logger logger = LoggerFactory.getLogger(UsageDataService.class); + + private final RepositoryService repositories; + private final CustomerService customerService; + private final JedisCluster jedisCluster; + + public UsageDataService(RepositoryService repositories, CustomerService customerService, JedisCluster jedisCluster) { + this.repositories = repositories; + this.customerService = customerService; + this.jedisCluster = jedisCluster; + } + + public void incrementUsage(Customer customer) { + var key = customer.getId(); + var window = getCurrentUsageWindow(); + var old = jedisCluster.hincrBy(USAGE_DATA_KEY, key + ":" + window, 1); + logger.info("Usage count for {}: {}", customer.getName(), old + 1); + } + + public void persistUsageStats() { + var currentWindow = getCurrentUsageWindow(); + + String cursor = ScanParams.SCAN_POINTER_START; + ScanResult> results; + + do { + results = jedisCluster.hscan(USAGE_DATA_KEY, cursor); + + for (var result : results.getResult()) { + var key = result.getKey(); + var value = result.getValue(); + + logger.info("{} - {}", key, value); + + var component = key.split(":"); + var customerId = Long.parseLong(component[0]); + var window = Long.parseLong(component[1]); + + if (window < currentWindow) { + var customer = customerService.getCustomerById(customerId); + if (customer.isEmpty()) { + logger.warn("failed to find customer with id {}", customerId); + } else { + UsageStats stats = new UsageStats(); + + stats.setCustomer(customer.get()); + stats.setWindowStart(LocalDateTime.ofInstant(Instant.ofEpochSecond(window * 60), ZoneOffset.UTC)); + stats.setCount(Long.parseLong(value)); + stats.setDuration(Duration.ofMinutes(WINDOW_MINUTES)); + repositories.saveUsageStats(stats); + } + + jedisCluster.hdel(USAGE_DATA_KEY, key); + } + } + + cursor = results.getCursor(); + } while (!results.isCompleteIteration()); + } + + private long getCurrentUsageWindow() { + var instant = Instant.now(); + var epochMinute = instant.getEpochSecond() / 60; + return epochMinute / WINDOW_MINUTES * WINDOW_MINUTES; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java index c3729e4b5..3f2a29de9 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java @@ -13,28 +13,89 @@ package org.eclipse.openvsx.ratelimit.config; import com.giffing.bucket4j.spring.boot.starter.filter.servlet.ServletRateLimiterFilterFactory; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Scheduler; import org.eclipse.openvsx.ratelimit.CustomerService; -import org.eclipse.openvsx.ratelimit.CustomerUsageService; +import org.eclipse.openvsx.ratelimit.UsageDataService; +import org.eclipse.openvsx.ratelimit.IdentityService; import org.eclipse.openvsx.ratelimit.filter.TieredRateLimitServletFilterFactory; import org.eclipse.openvsx.repositories.RepositoryService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cache.CacheManager; +import org.springframework.cache.caffeine.CaffeineCacheManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import redis.clients.jedis.JedisCluster; +import java.time.Duration; + @Configuration @ConditionalOnProperty(value = "ovsx.tiered-rate-limit.enabled", havingValue = "true") @ConditionalOnBean(JedisCluster.class) public class TieredRateLimitConfig { + private final Logger logger = LoggerFactory.getLogger(TieredRateLimitConfig.class); + + public static final String CACHE_RATE_LIMIT_CUSTOMER = "ratelimit.customer"; + public static final String CACHE_RATE_LIMIT_TOKEN = "ratelimit.token"; + @Bean - CustomerUsageService customerUsageService(RepositoryService repositories, JedisCluster jedisCluster) { - return new CustomerUsageService(repositories, jedisCluster); + UsageDataService usageDataService(RepositoryService repositories, CustomerService customerService, JedisCluster jedisCluster) { + return new UsageDataService(repositories, customerService, jedisCluster); } @Bean - ServletRateLimiterFilterFactory tieredServletFilterFactory(CustomerService customerService, CustomerUsageService customerUsageService) { - return new TieredRateLimitServletFilterFactory(customerService, customerUsageService); + ServletRateLimiterFilterFactory tieredServletFilterFactory( + UsageDataService + customerUsageService, + IdentityService identityService + ) { + return new TieredRateLimitServletFilterFactory(customerUsageService, identityService); + } + + @Bean + public Cache customerCache( + @Value("${ovsx.caching.customer.tti:PT1H}") Duration timeToIdle, + @Value("${ovsx.caching.customer.max-size:10000}") long maxSize + ) { + return Caffeine.newBuilder() + .expireAfterAccess(timeToIdle) + .maximumSize(maxSize) + .scheduler(Scheduler.systemScheduler()) + .recordStats() + .build(); + } + + @Bean + public Cache tokenCache( + @Value("${ovsx.caching.token.tti:PT1H}") Duration timeToIdle, + @Value("${ovsx.caching.token.max-size:10000}") long maxSize + ) { + return Caffeine.newBuilder() + .expireAfterAccess(timeToIdle) + .maximumSize(maxSize) + .scheduler(Scheduler.systemScheduler()) + .recordStats() + .build(); + } + + @Bean + @Qualifier("rateLimitCacheManager") + public CacheManager rateLimitCacheManager( + Cache customerCache, + Cache tokenCache + ) { + logger.info("Configure rate limit cache manager"); + CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager(); + caffeineCacheManager.registerCustomCache(CACHE_RATE_LIMIT_CUSTOMER, customerCache); + caffeineCacheManager.registerCustomCache(CACHE_RATE_LIMIT_TOKEN, tokenCache); + + return caffeineCacheManager; } } diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java index 6e54e022e..97c1d9564 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java @@ -21,8 +21,8 @@ import jakarta.servlet.*; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.eclipse.openvsx.ratelimit.CustomerService; -import org.eclipse.openvsx.ratelimit.CustomerUsageService; +import org.eclipse.openvsx.ratelimit.UsageDataService; +import org.eclipse.openvsx.ratelimit.IdentityService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.filter.OncePerRequestFilter; @@ -35,17 +35,17 @@ public class TieredRateLimitServletFilter extends OncePerRequestFilter implement private final Logger logger = LoggerFactory.getLogger(TieredRateLimitServletFilter.class); private FilterConfiguration filterConfig; - private final CustomerService customerService; - private final CustomerUsageService customerUsageService; + private final UsageDataService customerUsageService; + private final IdentityService identityService; public TieredRateLimitServletFilter( FilterConfiguration filterConfig, - CustomerService customerService, - CustomerUsageService customerUsageService + UsageDataService customerUsageService, + IdentityService identityService ) { this.filterConfig = filterConfig; - this.customerService = customerService; this.customerUsageService = customerUsageService; + this.identityService = identityService; } @Override @@ -62,9 +62,13 @@ protected boolean shouldNotFilter(HttpServletRequest request) throws ServletExce protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { logger.debug("rate limit filter: {}: {}", request.getRequestURI(), request.getRemoteAddr()); - var customer = customerService.getCustomer(request.getRemoteAddr()); - logger.info("handling rate limit for customer {}", customer); - customerUsageService.incrementUsage(request.getRemoteAddr()); + var identity = identityService.resolveIdentity(request); + + if (identity.isCustomer()) { + var customer = identity.getCustomer(); + logger.info("handling rate limit for customer {}", customer); + customerUsageService.incrementUsage(customer); + } boolean allConsumed = true; Long remainingLimit = null; diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java index 73d50355a..5e5c1cc81 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java @@ -17,20 +17,23 @@ import com.giffing.bucket4j.spring.boot.starter.filter.servlet.ServletRateLimiterFilterFactory; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.eclipse.openvsx.ratelimit.CustomerService; -import org.eclipse.openvsx.ratelimit.CustomerUsageService; +import org.eclipse.openvsx.ratelimit.UsageDataService; +import org.eclipse.openvsx.ratelimit.IdentityService; public class TieredRateLimitServletFilterFactory implements ServletRateLimiterFilterFactory { - private final CustomerService customerService; - private final CustomerUsageService usageService; + private final UsageDataService usageService; + private final IdentityService identityService; - public TieredRateLimitServletFilterFactory(CustomerService customerService, CustomerUsageService usageService) { - this.customerService = customerService; + public TieredRateLimitServletFilterFactory( + UsageDataService usageService, + IdentityService identityService + ) { this.usageService = usageService; + this.identityService = identityService; } @Override public ServletRateLimitFilter create(FilterConfiguration filterConfig) { - return new TieredRateLimitServletFilter(filterConfig, customerService, usageService); + return new TieredRateLimitServletFilter(filterConfig, usageService, identityService); } } diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java index 14f4eff0d..701f39edf 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java @@ -13,12 +13,15 @@ import org.springframework.data.repository.Repository; import java.util.List; +import java.util.Optional; public interface CustomerRepository extends Repository { + Optional findById(long id); + List findAll(); - Customer findByNameIgnoreCase(String name); + Optional findByNameIgnoreCase(String name); long count(); } 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 2272292af..29d4b87a3 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -25,6 +25,7 @@ import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.Optional; import static org.eclipse.openvsx.entities.FileResource.*; @@ -678,7 +679,11 @@ public List findAllCustomers() { return customerRepo.findAll(); } - public Customer findCustomer(String name) { + public Optional findCustomerById(long id) { + return customerRepo.findById(id); + } + + public Optional findCustomer(String name) { return customerRepo.findByNameIgnoreCase(name); } diff --git a/server/src/main/resources/db/migration/V1_58__Rate_Limit.sql b/server/src/main/resources/db/migration/V1_58__Rate_Limit.sql index 8320e581d..6389d6adf 100644 --- a/server/src/main/resources/db/migration/V1_58__Rate_Limit.sql +++ b/server/src/main/resources/db/migration/V1_58__Rate_Limit.sql @@ -19,9 +19,9 @@ ALTER TABLE ONLY public.tier -- create customer table CREATE TABLE IF NOT EXISTS public.customer (id BIGINT NOT NULL, name CHARACTER VARYING(255) NOT NULL, - tier_id bigint, - cidrs CHARACTER VARYING(2048) - ); + tier_id bigint, + cidr_blocks CHARACTER VARYING(2048) +); ALTER TABLE ONLY public.customer ADD CONSTRAINT customer_pkey PRIMARY KEY (id); @@ -38,7 +38,7 @@ SELECT SETVAL('customer_seq', (SELECT COALESCE(MAX(id), 1) FROM customer)::BIGIN -- create usage_stats table CREATE TABLE IF NOT EXISTS public.usage_stats (id BIGINT NOT NULL, customer_id BIGINT, - window_start BIGINT NOT NULL, + window_start TIMESTAMP WITHOUT TIME ZONE NOT NULL, duration INTEGER NOT NULL, count BIGINT NOT NULL ); From 4d9c6043b3b24ee6eaecd7d6157994e0f29036d6 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Mon, 19 Jan 2026 11:45:58 +0100 Subject: [PATCH 12/45] refactor jobs, schedule them based on tiered rate limiting enabled --- .../ratelimit/CollectUsageStatsJobs.java | 37 --------------- .../config/ScheduleRateLimitJobs.java | 45 +++++++++++++++++++ .../jobs/CollectUsageStatsJobRequest.java | 23 ++++++++++ .../CollectUsageStatsJobRequestHandler.java | 42 +++++++++++++++++ 4 files changed, 110 insertions(+), 37 deletions(-) delete mode 100644 server/src/main/java/org/eclipse/openvsx/ratelimit/CollectUsageStatsJobs.java create mode 100644 server/src/main/java/org/eclipse/openvsx/ratelimit/config/ScheduleRateLimitJobs.java create mode 100644 server/src/main/java/org/eclipse/openvsx/ratelimit/jobs/CollectUsageStatsJobRequest.java create mode 100644 server/src/main/java/org/eclipse/openvsx/ratelimit/jobs/CollectUsageStatsJobRequestHandler.java diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/CollectUsageStatsJobs.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/CollectUsageStatsJobs.java deleted file mode 100644 index 4fa6177bc..000000000 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/CollectUsageStatsJobs.java +++ /dev/null @@ -1,37 +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 - */ -package org.eclipse.openvsx.ratelimit; - -import org.jobrunr.jobs.annotations.Job; -import org.jobrunr.jobs.annotations.Recurring; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Component; - -@Component -public class CollectUsageStatsJobs { - private final Logger logger = LoggerFactory.getLogger(CollectUsageStatsJobs.class); - - private final UsageDataService usageDataService; - - public CollectUsageStatsJobs(UsageDataService usageDataService) { - this.usageDataService = usageDataService; - } - - @Job(name = "Collect usage stats", retries = 0) - @Recurring(id = "collect-usage-stats", cron = "*/15 * * * * *", zoneId = "UTC") - public void collect() { - logger.info("starting collect usage stats job"); - usageDataService.persistUsageStats(); - } -} diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/ScheduleRateLimitJobs.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/ScheduleRateLimitJobs.java new file mode 100644 index 000000000..5da65c0a1 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/ScheduleRateLimitJobs.java @@ -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 + */ +package org.eclipse.openvsx.ratelimit.config; + +import org.eclipse.openvsx.ratelimit.UsageDataService; +import org.eclipse.openvsx.ratelimit.jobs.CollectUsageStatsJobRequest; +import org.jobrunr.scheduling.JobRequestScheduler; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Component +public class ScheduleRateLimitJobs { + + private static final String COLLECT_USAGE_STATS_SCHEDULE = "*/15 * * * * *"; + + private UsageDataService usageDataService; + private final JobRequestScheduler scheduler; + + public ScheduleRateLimitJobs(Optional usageDataService, JobRequestScheduler scheduler) { + usageDataService.ifPresent(service -> this.usageDataService = service); + this.scheduler = scheduler; + } + + @EventListener + public void scheduleJobs(ApplicationStartedEvent event) { + if (usageDataService != null) { + scheduler.scheduleRecurrently("collect-usage-stats", COLLECT_USAGE_STATS_SCHEDULE, new CollectUsageStatsJobRequest()); + } else { + scheduler.deleteRecurringJob("collect-usage-stats"); + } + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/jobs/CollectUsageStatsJobRequest.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/jobs/CollectUsageStatsJobRequest.java new file mode 100644 index 000000000..c4d2f2b8d --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/jobs/CollectUsageStatsJobRequest.java @@ -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 + */ +package org.eclipse.openvsx.ratelimit.jobs; + +import org.jobrunr.jobs.lambdas.JobRequest; +import org.jobrunr.jobs.lambdas.JobRequestHandler; + +public class CollectUsageStatsJobRequest implements JobRequest { + @Override + public Class> getJobRequestHandler() { + return CollectUsageStatsJobRequestHandler.class; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/jobs/CollectUsageStatsJobRequestHandler.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/jobs/CollectUsageStatsJobRequestHandler.java new file mode 100644 index 000000000..285df3b76 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/jobs/CollectUsageStatsJobRequestHandler.java @@ -0,0 +1,42 @@ +/* + * 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.ratelimit.jobs; + +import org.eclipse.openvsx.ratelimit.UsageDataService; +import org.jobrunr.jobs.annotations.Job; +import org.jobrunr.jobs.lambdas.JobRequestHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Component +public class CollectUsageStatsJobRequestHandler implements JobRequestHandler { + private final Logger logger = LoggerFactory.getLogger(CollectUsageStatsJobRequestHandler.class); + + private UsageDataService usageDataService; + + public CollectUsageStatsJobRequestHandler(Optional usageDataService) { + usageDataService.ifPresent(service -> this.usageDataService = service); + } + + @Override + @Job(name = "Collect usage stats") + public void run(CollectUsageStatsJobRequest collectUsageStatsJobRequest) throws Exception { + if (usageDataService != null) { + logger.info("starting collect usage stats job"); + usageDataService.persistUsageStats(); + } + } +} From 5c7dfef98a19728aee06c369bf93296c47a19d3a Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Mon, 19 Jan 2026 14:10:46 +0100 Subject: [PATCH 13/45] add enforcement state --- .../db/migration/V1_58_1__RateLimit.sql | 2 +- .../org/eclipse/openvsx/entities/Customer.java | 15 ++++++++++++++- .../openvsx/entities/EnforcementState.java | 18 ++++++++++++++++++ .../db/migration/V1_58__Rate_Limit.sql | 1 + 4 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 server/src/main/java/org/eclipse/openvsx/entities/EnforcementState.java diff --git a/server/src/dev/resources/db/migration/V1_58_1__RateLimit.sql b/server/src/dev/resources/db/migration/V1_58_1__RateLimit.sql index b5f31bf37..cdb66e952 100644 --- a/server/src/dev/resources/db/migration/V1_58_1__RateLimit.sql +++ b/server/src/dev/resources/db/migration/V1_58_1__RateLimit.sql @@ -1,4 +1,4 @@ -- add basic tier and assign customer for loopback IP to it INSERT INTO tier (id, name, description, capacity, duration, refill_strategy) VALUES (1, 'basic', '', 100, 60, 'GREEDY'); -INSERT INTO customer (id, name, tier_id, cidr_blocks) VALUES (1, 'loopback', 1, '127.0.0.1/32'); +INSERT INTO customer (id, name, tier_id, state, cidr_blocks) VALUES (1, 'loopback', 1, 'EVALUATION', '127.0.0.1/32'); 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 f629b455b..1f4f2a7e6 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/Customer.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/Customer.java @@ -39,6 +39,10 @@ public class Customer implements Serializable { @ManyToOne private Tier tier; + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private EnforcementState state = EnforcementState.EVALUATION; + @Column(length = 2048) @Convert(converter = ListOfStringConverter.class) private List cidrBlocks = Collections.emptyList(); @@ -67,11 +71,19 @@ public void setTier(Tier tier) { this.tier = tier; } + public EnforcementState getState() { + return state; + } + + public void setState(EnforcementState state) { + this.state = state; + } + public List getCidrBlocks() { return cidrBlocks; } - public void setCidrs(List cidrBlocks) { + public void setCidrBlocks(List cidrBlocks) { this.cidrBlocks = cidrBlocks; } @@ -96,6 +108,7 @@ public String toString() { return "Customer{" + "name='" + name + '\'' + ", tier=" + tier + + ", state=" + state + ", cidrBlocks=" + cidrBlocks + '}'; } diff --git a/server/src/main/java/org/eclipse/openvsx/entities/EnforcementState.java b/server/src/main/java/org/eclipse/openvsx/entities/EnforcementState.java new file mode 100644 index 000000000..33a334b21 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/entities/EnforcementState.java @@ -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 + */ +package org.eclipse.openvsx.entities; + +public enum EnforcementState { + EVALUATION, + ENFORCEMENT +} diff --git a/server/src/main/resources/db/migration/V1_58__Rate_Limit.sql b/server/src/main/resources/db/migration/V1_58__Rate_Limit.sql index 6389d6adf..71b55e6f4 100644 --- a/server/src/main/resources/db/migration/V1_58__Rate_Limit.sql +++ b/server/src/main/resources/db/migration/V1_58__Rate_Limit.sql @@ -20,6 +20,7 @@ ALTER TABLE ONLY public.tier CREATE TABLE IF NOT EXISTS public.customer (id BIGINT NOT NULL, name CHARACTER VARYING(255) NOT NULL, tier_id bigint, + state CHARACTER VARYING(255) NOT NULL, cidr_blocks CHARACTER VARYING(2048) ); From 5a3eaf74534a860ef7b6d2eaf0d96021bd758109 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Wed, 21 Jan 2026 22:08:52 +0100 Subject: [PATCH 14/45] add bucket handling --- .../openvsx/ratelimit/IdentityService.java | 2 +- .../openvsx/ratelimit/ResolvedIdentity.java | 10 +- .../ratelimit/TieredRateLimitService.java | 32 +++++++ .../openvsx/ratelimit/UsageDataService.java | 6 +- .../config/ScheduleRateLimitJobs.java | 4 +- .../config/TieredRateLimitConfig.java | 28 +++++- .../filter/TieredRateLimitServletFilter.java | 93 ++++++++++++------- .../TieredRateLimitServletFilterFactory.java | 8 +- 8 files changed, 133 insertions(+), 50 deletions(-) create mode 100644 server/src/main/java/org/eclipse/openvsx/ratelimit/TieredRateLimitService.java diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/IdentityService.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/IdentityService.java index b78466df4..eba3d9c9c 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/IdentityService.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/IdentityService.java @@ -42,6 +42,6 @@ public ResolvedIdentity resolveIdentity(HttpServletRequest request) { } - return ResolvedIdentity.anonymous(); + return ResolvedIdentity.anonymous(ipAddress); } } diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/ResolvedIdentity.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/ResolvedIdentity.java index 3783a3c1f..79f23663d 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/ResolvedIdentity.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/ResolvedIdentity.java @@ -10,16 +10,14 @@ * * SPDX-License-Identifier: EPL-2.0 */ - package org.eclipse.openvsx.ratelimit; import jakarta.validation.constraints.NotNull; import org.eclipse.openvsx.entities.Customer; -import org.eclipse.openvsx.entities.UserData; import javax.annotation.Nullable; -public record ResolvedIdentity(@Nullable Customer customer) { +public record ResolvedIdentity(@Nullable Customer customer, String cacheKey) { public boolean isCustomer() { return customer != null; @@ -33,11 +31,11 @@ public boolean isCustomer() { } } - public static ResolvedIdentity anonymous() { - return new ResolvedIdentity(null); + public static ResolvedIdentity anonymous(String cacheKey) { + return new ResolvedIdentity(null, cacheKey); } public static ResolvedIdentity ofCustomer(Customer customer) { - return new ResolvedIdentity(customer); + return new ResolvedIdentity(customer, String.valueOf(customer.getId())); } } diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/TieredRateLimitService.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/TieredRateLimitService.java new file mode 100644 index 000000000..972f87dcf --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/TieredRateLimitService.java @@ -0,0 +1,32 @@ +/* + * 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.ratelimit; + +import io.github.bucket4j.Bucket; +import io.github.bucket4j.BucketConfiguration; +import io.github.bucket4j.distributed.proxy.ProxyManager; + +import java.nio.charset.StandardCharsets; + +public class TieredRateLimitService { + private final ProxyManager proxyManager; + + public TieredRateLimitService(ProxyManager proxyManager) { + this.proxyManager = proxyManager; + } + + public Bucket getBucket(String key, BucketConfiguration bucketConfiguration) { + Bucket bucket = proxyManager.builder().build(key.getBytes(StandardCharsets.UTF_8), bucketConfiguration); + return bucket; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/UsageDataService.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/UsageDataService.java index 1e3e3e4c3..020341dca 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/UsageDataService.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/UsageDataService.java @@ -28,8 +28,10 @@ import java.util.Map; public class UsageDataService { + public static final String COLLECT_USAGE_STATS_SCHEDULE = "*/15 * * * * *"; + private final static String USAGE_DATA_KEY = "usage.customer"; - private final static int WINDOW_MINUTES = 5; + private final static int WINDOW_MINUTES = 5; private final Logger logger = LoggerFactory.getLogger(UsageDataService.class); @@ -63,7 +65,7 @@ public void persistUsageStats() { var key = result.getKey(); var value = result.getValue(); - logger.info("{} - {}", key, value); + logger.debug("usage stats: {} - {}", key, value); var component = key.split(":"); var customerId = Long.parseLong(component[0]); diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/ScheduleRateLimitJobs.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/ScheduleRateLimitJobs.java index 5da65c0a1..725c1faae 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/ScheduleRateLimitJobs.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/ScheduleRateLimitJobs.java @@ -21,11 +21,11 @@ import java.util.Optional; +import static org.eclipse.openvsx.ratelimit.UsageDataService.COLLECT_USAGE_STATS_SCHEDULE; + @Component public class ScheduleRateLimitJobs { - private static final String COLLECT_USAGE_STATS_SCHEDULE = "*/15 * * * * *"; - private UsageDataService usageDataService; private final JobRequestScheduler scheduler; diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java index 3f2a29de9..e006eaf79 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java @@ -16,7 +16,12 @@ import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.github.benmanes.caffeine.cache.Scheduler; +import io.github.bucket4j.distributed.ExpirationAfterWriteStrategy; +import io.github.bucket4j.distributed.proxy.ClientSideConfig; +import io.github.bucket4j.distributed.proxy.ProxyManager; +import io.github.bucket4j.redis.jedis.cas.JedisBasedProxyManager; import org.eclipse.openvsx.ratelimit.CustomerService; +import org.eclipse.openvsx.ratelimit.TieredRateLimitService; import org.eclipse.openvsx.ratelimit.UsageDataService; import org.eclipse.openvsx.ratelimit.IdentityService; import org.eclipse.openvsx.ratelimit.filter.TieredRateLimitServletFilterFactory; @@ -26,6 +31,7 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.cache.CacheManager; import org.springframework.cache.caffeine.CaffeineCacheManager; @@ -50,13 +56,19 @@ UsageDataService usageDataService(RepositoryService repositories, CustomerServic return new UsageDataService(repositories, customerService, jedisCluster); } + @Bean + TieredRateLimitService tieredRateLimitService(ProxyManager proxyManager) { + return new TieredRateLimitService(proxyManager); + } + @Bean ServletRateLimiterFilterFactory tieredServletFilterFactory( UsageDataService customerUsageService, - IdentityService identityService + IdentityService identityService, + TieredRateLimitService rateLimitService ) { - return new TieredRateLimitServletFilterFactory(customerUsageService, identityService); + return new TieredRateLimitServletFilterFactory(customerUsageService, identityService, rateLimitService); } @Bean @@ -98,4 +110,16 @@ public CacheManager rateLimitCacheManager( return caffeineCacheManager; } + + @Bean + @ConditionalOnMissingBean(ProxyManager.class) + public ProxyManager jedisBasedProxyManager(JedisCluster jedisCluster) { + return JedisBasedProxyManager.builderFor(jedisCluster) + .withClientSideConfig( + ClientSideConfig + .getDefault() + .withExpirationAfterWriteStrategy(ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(Duration.ofSeconds(10)))) + .build(); + } + } diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java index 97c1d9564..3f02613e9 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java @@ -12,15 +12,16 @@ */ package org.eclipse.openvsx.ratelimit.filter; -import com.giffing.bucket4j.spring.boot.starter.context.ExpressionParams; -import com.giffing.bucket4j.spring.boot.starter.context.RateLimitConditionMatchingStrategy; import com.giffing.bucket4j.spring.boot.starter.context.RateLimitResult; import com.giffing.bucket4j.spring.boot.starter.context.properties.FilterConfiguration; import com.giffing.bucket4j.spring.boot.starter.filter.servlet.ServletRateLimitFilter; -import com.giffing.bucket4j.spring.boot.starter.service.RateLimitService; +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.BucketConfiguration; +import io.github.bucket4j.ConsumptionProbe; import jakarta.servlet.*; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.eclipse.openvsx.ratelimit.TieredRateLimitService; import org.eclipse.openvsx.ratelimit.UsageDataService; import org.eclipse.openvsx.ratelimit.IdentityService; import org.slf4j.Logger; @@ -28,6 +29,7 @@ import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.time.Duration; import java.util.concurrent.TimeUnit; public class TieredRateLimitServletFilter extends OncePerRequestFilter implements ServletRateLimitFilter { @@ -37,15 +39,18 @@ public class TieredRateLimitServletFilter extends OncePerRequestFilter implement private FilterConfiguration filterConfig; private final UsageDataService customerUsageService; private final IdentityService identityService; + private final TieredRateLimitService rateLimitService; public TieredRateLimitServletFilter( FilterConfiguration filterConfig, UsageDataService customerUsageService, - IdentityService identityService + IdentityService identityService, + TieredRateLimitService rateLimitService ) { this.filterConfig = filterConfig; this.customerUsageService = customerUsageService; this.identityService = identityService; + this.rateLimitService = rateLimitService; } @Override @@ -66,43 +71,61 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse if (identity.isCustomer()) { var customer = identity.getCustomer(); - logger.info("handling rate limit for customer {}", customer); + logger.info("updating usage status for customer {}", customer.getName()); customerUsageService.incrementUsage(customer); } - boolean allConsumed = true; - Long remainingLimit = null; - for (var rl : filterConfig.getRateLimitChecks()) { - var wrapper = rl.rateLimit(new ExpressionParams<>(request), null); - if (wrapper != null && wrapper.getRateLimitResult() != null) { - var rateLimitResult = wrapper.getRateLimitResult(); - if (rateLimitResult.isConsumed()) { - remainingLimit = RateLimitService.getRemainingLimit(remainingLimit, rateLimitResult); - } else { - allConsumed = false; - handleHttpResponseOnRateLimiting(response, rateLimitResult); - break; - } - if (filterConfig.getStrategy().equals(RateLimitConditionMatchingStrategy.FIRST)) { - break; - } - } - } + var bucketConfiguration = BucketConfiguration.builder() + .addLimit(Bandwidth.simple(200L, Duration.ofMinutes(1L))) + .build(); + + var bucket = rateLimitService.getBucket(identity.cacheKey(), bucketConfiguration); - if (allConsumed) { - if (remainingLimit != null && Boolean.FALSE.equals(filterConfig.getHideHttpResponseHeaders())) { - logger.debug("add-x-rate-limit-remaining-header;limit:{}", remainingLimit); - response.setHeader("X-Rate-Limit-Remaining", "" + remainingLimit); - } + ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1); + logger.info(">>>>>>>> remainingTokens: {}", probe.getRemainingTokens()); + if (probe.isConsumed()) { chain.doFilter(request, response); - filterConfig.getPostRateLimitChecks() - .forEach(rlc -> { - var result = rlc.rateLimit(request, response); - if (result != null) { - logger.debug("post-rate-limit;remaining-tokens:{}", result.getRateLimitResult().getRemainingTokens()); - } - }); + } else { + HttpServletResponse httpResponse = (HttpServletResponse) response; + httpResponse.setContentType("text/plain"); + httpResponse.setHeader("X-Rate-Limit-Retry-After-Seconds", "" + TimeUnit.NANOSECONDS.toSeconds(probe.getNanosToWaitForRefill())); + httpResponse.setStatus(429); + httpResponse.getWriter().append("Too many requests"); } + +// boolean allConsumed = true; +// Long remainingLimit = null; +// for (var rl : filterConfig.getRateLimitChecks()) { +// var wrapper = rl.rateLimit(new ExpressionParams<>(request), null); +// if (wrapper != null && wrapper.getRateLimitResult() != null) { +// var rateLimitResult = wrapper.getRateLimitResult(); +// if (rateLimitResult.isConsumed()) { +// remainingLimit = RateLimitService.getRemainingLimit(remainingLimit, rateLimitResult); +// } else { +// allConsumed = false; +// handleHttpResponseOnRateLimiting(response, rateLimitResult); +// break; +// } +// if (filterConfig.getStrategy().equals(RateLimitConditionMatchingStrategy.FIRST)) { +// break; +// } +// } +// } +// +// if (allConsumed) { +// if (remainingLimit != null && Boolean.FALSE.equals(filterConfig.getHideHttpResponseHeaders())) { +// logger.debug("add-x-rate-limit-remaining-header;limit:{}", remainingLimit); +// response.setHeader("X-Rate-Limit-Remaining", "" + remainingLimit); +// } +// chain.doFilter(request, response); +// filterConfig.getPostRateLimitChecks() +// .forEach(rlc -> { +// var result = rlc.rateLimit(request, response); +// if (result != null) { +// logger.debug("post-rate-limit;remaining-tokens:{}", result.getRateLimitResult().getRemainingTokens()); +// } +// }); +// } } private void handleHttpResponseOnRateLimiting(HttpServletResponse httpResponse, RateLimitResult rateLimitResult) throws IOException { diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java index 5e5c1cc81..1fa75e2ee 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java @@ -17,23 +17,27 @@ import com.giffing.bucket4j.spring.boot.starter.filter.servlet.ServletRateLimiterFilterFactory; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.eclipse.openvsx.ratelimit.TieredRateLimitService; import org.eclipse.openvsx.ratelimit.UsageDataService; import org.eclipse.openvsx.ratelimit.IdentityService; public class TieredRateLimitServletFilterFactory implements ServletRateLimiterFilterFactory { private final UsageDataService usageService; private final IdentityService identityService; + private final TieredRateLimitService rateLimitService; public TieredRateLimitServletFilterFactory( UsageDataService usageService, - IdentityService identityService + IdentityService identityService, + TieredRateLimitService rateLimitService ) { this.usageService = usageService; this.identityService = identityService; + this.rateLimitService = rateLimitService; } @Override public ServletRateLimitFilter create(FilterConfiguration filterConfig) { - return new TieredRateLimitServletFilter(filterConfig, usageService, identityService); + return new TieredRateLimitServletFilter(filterConfig, usageService, identityService, rateLimitService); } } From 7efb0969a4aae95a7885cb396534a93682f04be3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez=20Hidalgo?= <31970428+gnugomez@users.noreply.github.com> Date: Wed, 21 Jan 2026 22:17:00 +0100 Subject: [PATCH 15/45] feat: adding tier admin view (#1556) * feat: adding tier admin view * Toggle database search and elasticsearch settings * Fix indentation in application.yml --------- Co-authored-by: Thomas Neidhart --- webui/src/extension-registry-service.ts | 84 +++++- webui/src/extension-registry-types.ts | 14 + .../pages/admin-dashboard/admin-dashboard.tsx | 5 + .../pages/admin-dashboard/publisher-admin.tsx | 8 +- .../tiers/delete-tier-dialog.tsx | 68 +++++ .../tiers/tier-form-dialog.tsx | 240 ++++++++++++++++++ .../src/pages/admin-dashboard/tiers/tiers.tsx | 209 +++++++++++++++ 7 files changed, 619 insertions(+), 9 deletions(-) create mode 100644 webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx create mode 100644 webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx create mode 100644 webui/src/pages/admin-dashboard/tiers/tiers.tsx diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index c90357ba0..625728afb 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -12,7 +12,7 @@ import { Extension, UserData, ExtensionCategory, ExtensionReviewList, PersonalAccessToken, SearchResult, NewReview, SuccessResult, ErrorResult, CsrfTokenJson, isError, Namespace, NamespaceDetails, MembershipRole, SortBy, SortOrder, UrlString, NamespaceMembershipList, PublisherInfo, SearchEntry, RegistryVersion, - LoginProviders + LoginProviders, Tier, RefillStrategy } from './extension-registry-types'; import { createAbsoluteURL, addQuery } from './utils'; import { sendRequest, ErrorResponse } from './server-request'; @@ -486,15 +486,47 @@ export interface AdminService { getPublisherInfo(abortController: AbortController, provider: string, login: string): Promise> revokePublisherContributions(abortController: AbortController, provider: string, login: string): Promise> revokeAccessTokens(abortController: AbortController, provider: string, login: string): Promise> + getAllTiers(): Promise>; + getTierById(id: number): Promise>; + createTier(tier: Omit): Promise>; + updateTier(id: number, tier: Omit): Promise>; + deleteTier(id: number): Promise>; } -export interface AdminServiceConstructor { - new (registry: ExtensionRegistryService): AdminService -} +export type AdminServiceConstructor = new (registry: ExtensionRegistryService) => AdminService; export class AdminServiceImpl implements AdminService { - constructor(readonly registry: ExtensionRegistryService) {} + constructor(readonly registry: ExtensionRegistryService) { + this.initializeMockTiers(); + } + + private readonly tierCounter = { value: 1 }; + private readonly mockTiers: Map = new Map(); + + private initializeMockTiers(): void { + if (this.mockTiers.size === 0) { + const sampleTiers: Tier[] = [ + { + id: this.tierCounter.value++, + name: 'Free', + description: 'Free tier with basic rate limiting', + capacity: 100, + duration: 3600, + refillStrategy: RefillStrategy.GREEDY + }, + { + id: this.tierCounter.value++, + name: 'Professional', + description: 'Professional tier with higher rate limits', + capacity: 1000, + duration: 3600, + refillStrategy: RefillStrategy.GREEDY + } + ]; + sampleTiers.forEach(tier => this.mockTiers.set(tier.id, tier)); + } + } getExtension(abortController: AbortController, namespace: string, extension: string): Promise> { return sendRequest({ @@ -609,6 +641,48 @@ export class AdminServiceImpl implements AdminService { headers }); } + + async getAllTiers(): Promise> { + return Array.from(this.mockTiers.values()); + } + + async getTierById(id: number): Promise> { + const tier = this.mockTiers.get(id); + if (!tier) { + throw new Error(`Tier with ID ${id} not found`); + } + return tier; + } + + async createTier(tier: Omit): Promise> { + const newTier: Tier = { + id: this.tierCounter.value++, + ...tier + }; + this.mockTiers.set(newTier.id, newTier); + return newTier; + } + + async updateTier(id: number, tier: Omit): Promise> { + const existingTier = this.mockTiers.get(id); + if (!existingTier) { + throw new Error(`Tier with ID ${id} not found`); + } + + const updatedTier: Tier = { + ...existingTier, + ...tier + }; + this.mockTiers.set(id, updatedTier); + return updatedTier; + } + + async deleteTier(id: number): Promise { + if (!this.mockTiers.has(id)) { + throw new Error(`Tier with ID ${id} not found`); + } + this.mockTiers.delete(id); + } } export interface ExtensionFilter { diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index 6d50efb48..9d789ff1b 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -258,3 +258,17 @@ export interface LoginProviders { export type MembershipRole = 'contributor' | 'owner'; export type SortBy = 'relevance' | 'timestamp' | 'rating' | 'downloadCount'; export type SortOrder = 'asc' | 'desc'; + +export enum RefillStrategy { + GREEDY = 'GREEDY', + INTERVAL = 'INTERVAL', +} + +export interface Tier { + id: number; + name: string; + description?: string; + capacity: number; + duration: number; + refillStrategy: RefillStrategy; +} \ No newline at end of file diff --git a/webui/src/pages/admin-dashboard/admin-dashboard.tsx b/webui/src/pages/admin-dashboard/admin-dashboard.tsx index 4df98e83f..8f1b2ed3c 100644 --- a/webui/src/pages/admin-dashboard/admin-dashboard.tsx +++ b/webui/src/pages/admin-dashboard/admin-dashboard.tsx @@ -23,6 +23,8 @@ import HighlightOffIcon from '@mui/icons-material/HighlightOff'; import { Welcome } from './welcome'; import { PublisherAdmin } from './publisher-admin'; import PersonIcon from '@mui/icons-material/Person'; +import StarIcon from '@mui/icons-material/Star'; +import { Tiers } from './tiers/tiers'; export namespace AdminDashboardRoutes { export const ROOT = 'admin-dashboard'; @@ -30,6 +32,7 @@ export namespace AdminDashboardRoutes { export const NAMESPACE_ADMIN = createRoute([ROOT, 'namespaces']); export const EXTENSION_ADMIN = createRoute([ROOT, 'extensions']); export const PUBLISHER_ADMIN = createRoute([ROOT, 'publisher']); + export const TIERS = createRoute([ROOT, 'tiers']); } const Message: FunctionComponent<{message: string}> = ({ message }) => { @@ -59,6 +62,7 @@ export const AdminDashboard: FunctionComponent = props => { } route={AdminDashboardRoutes.NAMESPACE_ADMIN} /> } route={AdminDashboardRoutes.EXTENSION_ADMIN} /> } route={AdminDashboardRoutes.PUBLISHER_ADMIN} /> + } route={AdminDashboardRoutes.TIERS} /> @@ -69,6 +73,7 @@ export const AdminDashboard: FunctionComponent = props => { } /> } /> } /> + } /> } /> diff --git a/webui/src/pages/admin-dashboard/publisher-admin.tsx b/webui/src/pages/admin-dashboard/publisher-admin.tsx index 7595ab14e..c7620813a 100644 --- a/webui/src/pages/admin-dashboard/publisher-admin.tsx +++ b/webui/src/pages/admin-dashboard/publisher-admin.tsx @@ -39,13 +39,13 @@ export const PublisherAdmin: FunctionComponent = props => { const publisherName = inputValue; try { setLoading(true); - if (publisherName !== '') { - const publisher = await service.admin.getPublisherInfo(abortController.current, 'github', publisherName); + if (publisherName === '') { setNotFound(''); - setPublisher(publisher); + setPublisher(undefined); } else { + const publisher = await service.admin.getPublisherInfo(abortController.current, 'github', publisherName); setNotFound(''); - setPublisher(undefined); + setPublisher(publisher); } setLoading(false); } catch (err) { diff --git a/webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx b/webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx new file mode 100644 index 000000000..474216e82 --- /dev/null +++ b/webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx @@ -0,0 +1,68 @@ +import React, { FC, useState } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, + CircularProgress, + Alert +} from '@mui/material'; +import type { Tier } from '../../../extension-registry-types'; + +interface DeleteTierDialogProps { + open: boolean; + tier?: Tier; + onClose: () => void; + onConfirm: () => Promise; +} + +export const DeleteTierDialog: FC = ({ open, tier, onClose, onConfirm }) => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleConfirm = async () => { + try { + setError(null); + setLoading(true); + await onConfirm(); + onClose(); + } catch (err: any) { + setError(err.message || 'An error occurred while deleting the tier'); + setLoading(false); + } + }; + + return ( + + Delete Tier + + {error && {error}} + + + Are you sure you want to delete the tier {tier?.name}? + + + + This action cannot be undone. If this tier is assigned to customers, they will be affected. + + + + + + + + + ); +}; diff --git a/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx b/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx new file mode 100644 index 000000000..366e8aae2 --- /dev/null +++ b/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx @@ -0,0 +1,240 @@ +import React, { FC, useState, useEffect } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + Button, + FormControl, + InputLabel, + Select, + MenuItem, + CircularProgress, + Alert, + Box +} from '@mui/material'; +import type { SelectChangeEvent } from '@mui/material'; +import { RefillStrategy, type Tier } from "../../../extension-registry-types"; + +type DurationUnit = 'seconds' | 'minutes' | 'hours' | 'days'; + +const DURATION_MULTIPLIERS: Record = { + seconds: 1, + minutes: 60, + hours: 3600, + days: 86400 +}; + +interface TierFormDialogProps { + open: boolean; + tier?: Tier; + onClose: () => void; + onSubmit: (formData: Omit) => Promise; +} + +export const TierFormDialog: FC = ({ open, tier, onClose, onSubmit }) => { + const [formData, setFormData] = useState>({ + name: '', + description: '', + capacity: 100, + duration: 3600, + refillStrategy: RefillStrategy.INTERVAL + }); + const [durationValue, setDurationValue] = useState(1); + const [durationUnit, setDurationUnit] = useState('hours'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const getDurationInSeconds = (): number => { + return durationValue * DURATION_MULTIPLIERS[durationUnit]; + }; + + useEffect(() => { + if (tier) { + setFormData(prev => ({ + name: tier.name, + description: tier.description || '', + capacity: tier.capacity, + duration: tier.duration, + refillStrategy: tier.refillStrategy as any + })); + // Convert duration seconds to hours for display + setDurationValue(Math.floor(tier.duration / 3600)); + setDurationUnit('hours'); + } else { + setFormData(prev => ({ + ...prev, + name: '', + description: '', + capacity: 100, + duration: 3600, + refillStrategy: RefillStrategy.INTERVAL + })); + setDurationValue(1); + setDurationUnit('hours'); + } + setError(null); + }, [open, tier]); + + const handleChange = (e: React.ChangeEvent | SelectChangeEvent) => { + const { name, value } = e.target as any; + setFormData((prev: Omit) => ({ + ...prev, + [name]: name === 'capacity' || name === 'duration' ? Number.parseInt(value as string, 10) : value + } as Omit)); + }; + + const handleSubmit = async () => { + try { + setError(null); + setLoading(true); + + // Basic validation + if (!formData.name.trim()) { + setError('Tier name is required'); + setLoading(false); + return; + } + + if (formData.capacity <= 0) { + setError('Capacity must be greater than 0'); + setLoading(false); + return; + } + + if (durationValue <= 0) { + setError('Duration must be greater than 0'); + setLoading(false); + return; + } + + const durationInSeconds = getDurationInSeconds(); + await onSubmit({ + ...formData, + duration: durationInSeconds + }); + onClose(); + } catch (err: any) { + setError(err.message || 'An error occurred while saving the tier'); + setLoading(false); + } + }; + + const isEditMode = !!tier; + const title = isEditMode ? 'Edit Tier' : 'Create New Tier'; + + return ( + + {title} + + + {error && {error}} + + + + + + + + + setDurationValue(Math.max(1, Number.parseInt(e.target.value, 10) || 0))} + inputProps={{ min: '1' }} + disabled={loading} + required={true} + sx={{ flex: 1 }} + /> + + Unit + + + + + = {getDurationInSeconds().toLocaleString()} seconds + + + + Refill Strategy + + + + + + + + + + + + + ); +}; diff --git a/webui/src/pages/admin-dashboard/tiers/tiers.tsx b/webui/src/pages/admin-dashboard/tiers/tiers.tsx new file mode 100644 index 000000000..f5cdc02c1 --- /dev/null +++ b/webui/src/pages/admin-dashboard/tiers/tiers.tsx @@ -0,0 +1,209 @@ +import React, { FC, useState, useEffect } from "react"; +import { + Box, + Button, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, + CircularProgress, + Alert, + IconButton, + Stack +} from "@mui/material"; +import EditIcon from "@mui/icons-material/Edit"; +import DeleteIcon from "@mui/icons-material/Delete"; +import AddIcon from "@mui/icons-material/Add"; +import { MainContext } from "../../../context"; +import type { Tier, TierFormData } from "../../../extension-registry-types"; +import { TierFormDialog } from "./tier-form-dialog"; +import { DeleteTierDialog } from "./delete-tier-dialog"; + +export const Tiers: FC = () => { + const { service } = React.useContext(MainContext); + const [tiers, setTiers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [formDialogOpen, setFormDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [selectedTier, setSelectedTier] = useState(); + + // Load all tiers + const loadTiers = async () => { + try { + setLoading(true); + setError(null); + const data = await service.admin.getAllTiers(); + setTiers(data as Tier[]); + } catch (err: any) { + setError(err.message || "Failed to load tiers"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadTiers(); + }, []); + + const handleCreateClick = () => { + setSelectedTier(undefined); + setFormDialogOpen(true); + }; + + const handleEditClick = (tier: Tier) => { + setSelectedTier(tier); + setFormDialogOpen(true); + }; + + const handleDeleteClick = (tier: Tier) => { + setSelectedTier(tier); + setDeleteDialogOpen(true); + }; + + const handleFormSubmit = async (formData: TierFormData) => { + try { + if (selectedTier) { + // Update existing tier + await service.admin.updateTier(selectedTier.id, formData); + } else { + // Create new tier + await service.admin.createTier(formData); + } + await loadTiers(); + } catch (err: any) { + throw new Error(err.message || "Failed to save tier"); + } + }; + + const handleDeleteConfirm = async () => { + try { + if (selectedTier) { + await service.admin.deleteTier(selectedTier.id); + await loadTiers(); + } + } catch (err: any) { + throw new Error(err.message || "Failed to delete tier"); + } + }; + + const handleFormDialogClose = () => { + setFormDialogOpen(false); + setSelectedTier(undefined); + }; + + const handleDeleteDialogClose = () => { + setDeleteDialogOpen(false); + setSelectedTier(undefined); + }; + + return ( + + + + Tiers Management + + + + + {error && ( + setError(null)}> + {error} + + )} + + {loading && ( + + + + )} + + {!loading && tiers.length === 0 && ( + + + No tiers found. Create one to get started. + + + )} + + {!loading && tiers.length > 0 && ( + + + + + Name + Description + + Capacity + + + Duration (s) + + Refill Strategy + + Actions + + + + + {tiers.map(tier => ( + + {tier.name} + {tier.description || "-"} + {tier.capacity.toLocaleString()} + {tier.duration.toLocaleString()} + {tier.refillStrategy} + + + handleEditClick(tier)} + title='Edit tier' + color='primary' + > + + + handleDeleteClick(tier)} + title='Delete tier' + color='error' + > + + + + + + ))} + +
+
+ )} + + + + +
+ ); +}; From 5a7abedc595c1a2d41cf3df94225bc418ca0b718 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Wed, 21 Jan 2026 22:34:07 +0100 Subject: [PATCH 16/45] fix missing type, formatting --- webui/src/extension-registry-types.ts | 4 +++- webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index 9d789ff1b..adcb248d5 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -271,4 +271,6 @@ export interface Tier { capacity: number; duration: number; refillStrategy: RefillStrategy; -} \ No newline at end of file +} + +export type TierFormData = Omit; diff --git a/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx b/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx index 366e8aae2..4e31e97ba 100644 --- a/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx +++ b/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx @@ -15,7 +15,7 @@ import { Box } from '@mui/material'; import type { SelectChangeEvent } from '@mui/material'; -import { RefillStrategy, type Tier } from "../../../extension-registry-types"; +import { RefillStrategy, type Tier, TierFormData } from "../../../extension-registry-types"; type DurationUnit = 'seconds' | 'minutes' | 'hours' | 'days'; @@ -34,7 +34,7 @@ interface TierFormDialogProps { } export const TierFormDialog: FC = ({ open, tier, onClose, onSubmit }) => { - const [formData, setFormData] = useState>({ + const [formData, setFormData] = useState({ name: '', description: '', capacity: 100, @@ -69,7 +69,7 @@ export const TierFormDialog: FC = ({ open, tier, onClose, o description: '', capacity: 100, duration: 3600, - refillStrategy: RefillStrategy.INTERVAL + refillStrategy: RefillStrategy.INTERVAL })); setDurationValue(1); setDurationUnit('hours'); From ce5ab1b02685b21220e9b527b214b54b359ad7ed Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Thu, 22 Jan 2026 11:26:05 +0100 Subject: [PATCH 17/45] implement tier related api requests --- .../eclipse/openvsx/admin/RateLimitAPI.java | 129 ++++++++++++++++++ .../org/eclipse/openvsx/entities/Tier.java | 24 ++++ .../org/eclipse/openvsx/json/TierJson.java | 24 ++++ .../eclipse/openvsx/json/TierListJson.java | 22 +++ .../repositories/RepositoryService.java | 8 +- .../openvsx/repositories/TierRepository.java | 9 +- webui/src/extension-registry-service.ts | 124 ++++++++--------- webui/src/extension-registry-types.ts | 4 +- .../tiers/delete-tier-dialog.tsx | 13 ++ .../tiers/tier-form-dialog.tsx | 31 +++-- .../src/pages/admin-dashboard/tiers/tiers.tsx | 54 +++++--- webui/src/server-request.ts | 6 +- 12 files changed, 344 insertions(+), 104 deletions(-) create mode 100644 server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/TierJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/TierListJson.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 new file mode 100644 index 000000000..8551126a0 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java @@ -0,0 +1,129 @@ +/* + * 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.admin; + +import org.eclipse.openvsx.entities.Tier; +import org.eclipse.openvsx.json.ResultJson; +import org.eclipse.openvsx.json.TierJson; +import org.eclipse.openvsx.json.TierListJson; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.util.ErrorResultException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataAccessException; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + + +@RestController +@RequestMapping("/admin/ratelimit") +public class RateLimitAPI { + private final Logger logger = LoggerFactory.getLogger(RateLimitAPI.class); + + private final RepositoryService repositories; + private final AdminService admins; + + public RateLimitAPI( + RepositoryService repositories, + AdminService admins + ) { + this.repositories = repositories; + this.admins = admins; + } + + @GetMapping( + path = "/tiers", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity getTiers() { + try { + admins.checkAdminUser(); + + var tiers = repositories.findAllTiers(); + var result = new TierListJson(tiers.stream().map(Tier::toJson).toList()); + return ResponseEntity.ok(result); + } catch (Exception exc) { + logger.error("failed retrieving tiers", exc); + return ResponseEntity.internalServerError().build(); + } + } + + @PostMapping( + path = "/tiers/create", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity createTier(@RequestBody TierJson tier) { + try { + admins.checkAdminUser(); + + var existingTier = repositories.findTier(tier.name()); + if (existingTier != null) { + return ResponseEntity.badRequest().build(); + } + + var savedTier = repositories.upsertTier(Tier.fromJson(tier)); + return ResponseEntity.ok(savedTier.toJson()); + } catch (Exception exc) { + logger.error("failed creating tier {}", tier.name(), exc); + return ResponseEntity.internalServerError().build(); + } + } + + @PutMapping( + path = "/tiers/{name}", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity updateTier(@PathVariable String name, @RequestBody TierJson tier) { + try { + admins.checkAdminUser(); + + var savedTier = repositories.findTier(name); + if (savedTier == null) { + return ResponseEntity.notFound().build(); + } + + savedTier.updateFromJson(tier); + savedTier = repositories.upsertTier(savedTier); + + return ResponseEntity.ok(savedTier.toJson()); + } catch (Exception exc) { + logger.error("failed updating tier {}", name, exc); + return ResponseEntity.internalServerError().build(); + } + } + + @DeleteMapping( + path = "/tiers/{name}", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity deleteTier(@PathVariable String name) { + try { + admins.checkAdminUser(); + + var savedTier = repositories.findTier(name); + if (savedTier == null) { + return ResponseEntity.notFound().build(); + } + + repositories.deleteTier(savedTier); + + return ResponseEntity.ok(ResultJson.success("Deleted tier '" + name + "'")); + } catch (Exception exc) { + logger.error("failed deleting tier {}", name, exc); + return ResponseEntity.internalServerError().build(); + } + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/entities/Tier.java b/server/src/main/java/org/eclipse/openvsx/entities/Tier.java index bae6d51c1..d77d00575 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/Tier.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/Tier.java @@ -13,6 +13,7 @@ package org.eclipse.openvsx.entities; import jakarta.persistence.*; +import org.eclipse.openvsx.json.TierJson; import java.io.Serial; import java.io.Serializable; @@ -89,6 +90,29 @@ public void setRefillStrategy(RefillStrategy refillStrategy) { this.refillStrategy = refillStrategy; } + public TierJson toJson() { + return new TierJson(name, description, capacity, duration.toSeconds(), refillStrategy.name()); + } + + public Tier updateFromJson(TierJson json) { + setName(json.name()); + setDescription(json.description()); + setCapacity(json.capacity()); + setDuration(Duration.ofSeconds(json.duration())); + setRefillStrategy(RefillStrategy.valueOf(json.refillStrategy())); + return this; + } + + public static Tier fromJson(TierJson json) { + var tier = new Tier(); + tier.setName(json.name()); + tier.setDescription(json.description()); + tier.setCapacity(json.capacity()); + tier.setDuration(Duration.ofSeconds(json.duration())); + tier.setRefillStrategy(RefillStrategy.valueOf(json.refillStrategy())); + return tier; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/server/src/main/java/org/eclipse/openvsx/json/TierJson.java b/server/src/main/java/org/eclipse/openvsx/json/TierJson.java new file mode 100644 index 000000000..21ca14870 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/TierJson.java @@ -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 + */ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record TierJson( + String name, + String description, + int capacity, + long duration, + String refillStrategy +) {} diff --git a/server/src/main/java/org/eclipse/openvsx/json/TierListJson.java b/server/src/main/java/org/eclipse/openvsx/json/TierListJson.java new file mode 100644 index 000000000..d158bee6f --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/TierListJson.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 java.util.List; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record TierListJson( + List tiers +) {} 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 29d4b87a3..24d7fe9f1 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -699,8 +699,12 @@ public Tier findTier(String name) { return tierRepo.findByNameIgnoreCase(name); } - public long countTiers() { - return tierRepo.count(); + public Tier upsertTier(Tier tier) { + return tierRepo.save(tier); + } + + public void deleteTier(Tier tier) { + tierRepo.delete(tier); } @Transactional diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/TierRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/TierRepository.java index d0d9cc17c..621f35544 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/TierRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/TierRepository.java @@ -10,15 +10,12 @@ package org.eclipse.openvsx.repositories; import org.eclipse.openvsx.entities.Tier; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.CrudRepository; import org.springframework.data.repository.Repository; import java.util.List; -public interface TierRepository extends Repository { - - List findAll(); - +public interface TierRepository extends JpaRepository { Tier findByNameIgnoreCase(String name); - - long count(); } diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index 625728afb..c088a7e11 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -12,7 +12,7 @@ import { Extension, UserData, ExtensionCategory, ExtensionReviewList, PersonalAccessToken, SearchResult, NewReview, SuccessResult, ErrorResult, CsrfTokenJson, isError, Namespace, NamespaceDetails, MembershipRole, SortBy, SortOrder, UrlString, NamespaceMembershipList, PublisherInfo, SearchEntry, RegistryVersion, - LoginProviders, Tier, RefillStrategy + LoginProviders, Tier, TierList } from './extension-registry-types'; import { createAbsoluteURL, addQuery } from './utils'; import { sendRequest, ErrorResponse } from './server-request'; @@ -486,47 +486,17 @@ export interface AdminService { getPublisherInfo(abortController: AbortController, provider: string, login: string): Promise> revokePublisherContributions(abortController: AbortController, provider: string, login: string): Promise> revokeAccessTokens(abortController: AbortController, provider: string, login: string): Promise> - getAllTiers(): Promise>; - getTierById(id: number): Promise>; - createTier(tier: Omit): Promise>; - updateTier(id: number, tier: Omit): Promise>; - deleteTier(id: number): Promise>; + getTiers(abortController: AbortController): Promise>; + createTier(abortController: AbortController, tier: Tier): Promise>; + updateTier(abortController: AbortController, name: string, tier: Tier): Promise>; + deleteTier(abortController: AbortController, name: string): Promise>; } export type AdminServiceConstructor = new (registry: ExtensionRegistryService) => AdminService; export class AdminServiceImpl implements AdminService { - constructor(readonly registry: ExtensionRegistryService) { - this.initializeMockTiers(); - } - - private readonly tierCounter = { value: 1 }; - private readonly mockTiers: Map = new Map(); - - private initializeMockTiers(): void { - if (this.mockTiers.size === 0) { - const sampleTiers: Tier[] = [ - { - id: this.tierCounter.value++, - name: 'Free', - description: 'Free tier with basic rate limiting', - capacity: 100, - duration: 3600, - refillStrategy: RefillStrategy.GREEDY - }, - { - id: this.tierCounter.value++, - name: 'Professional', - description: 'Professional tier with higher rate limits', - capacity: 1000, - duration: 3600, - refillStrategy: RefillStrategy.GREEDY - } - ]; - sampleTiers.forEach(tier => this.mockTiers.set(tier.id, tier)); - } - } + constructor(readonly registry: ExtensionRegistryService) {} getExtension(abortController: AbortController, namespace: string, extension: string): Promise> { return sendRequest({ @@ -642,46 +612,68 @@ export class AdminServiceImpl implements AdminService { }); } - async getAllTiers(): Promise> { - return Array.from(this.mockTiers.values()); + async getTiers(abortController: AbortController): Promise> { + return sendRequest({ + abortController, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'tiers']), + credentials: true + }); } - async getTierById(id: number): Promise> { - const tier = this.mockTiers.get(id); - if (!tier) { - throw new Error(`Tier with ID ${id} not found`); + async createTier(abortController: AbortController, tier: Tier): Promise> { + const csrfResponse = await this.registry.getCsrfToken(abortController); + const headers: Record = { + 'Content-Type': 'application/json;charset=UTF-8' + }; + if (!isError(csrfResponse)) { + const csrfToken = csrfResponse as CsrfTokenJson; + headers[csrfToken.header] = csrfToken.value; } - return tier; + return sendRequest({ + abortController, + method: 'POST', + payload: tier, + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'tiers', 'create']), + headers + }, false); } - async createTier(tier: Omit): Promise> { - const newTier: Tier = { - id: this.tierCounter.value++, - ...tier + async updateTier(abortController: AbortController, name: string, tier: Tier): Promise> { + const csrfResponse = await this.registry.getCsrfToken(abortController); + const headers: Record = { + 'Content-Type': 'application/json;charset=UTF-8' }; - this.mockTiers.set(newTier.id, newTier); - return newTier; - } - - async updateTier(id: number, tier: Omit): Promise> { - const existingTier = this.mockTiers.get(id); - if (!existingTier) { - throw new Error(`Tier with ID ${id} not found`); + if (!isError(csrfResponse)) { + const csrfToken = csrfResponse as CsrfTokenJson; + headers[csrfToken.header] = csrfToken.value; } - - const updatedTier: Tier = { - ...existingTier, - ...tier - }; - this.mockTiers.set(id, updatedTier); - return updatedTier; + return sendRequest({ + abortController, + method: 'PUT', + payload: tier, + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'tiers', name]), + headers + }, false); } - async deleteTier(id: number): Promise { - if (!this.mockTiers.has(id)) { - throw new Error(`Tier with ID ${id} not found`); + async deleteTier(abortController: AbortController, name: string): Promise { + const csrfResponse = await this.registry.getCsrfToken(abortController); + const headers: Record = { + 'Content-Type': 'application/json;charset=UTF-8' + }; + if (!isError(csrfResponse)) { + const csrfToken = csrfResponse as CsrfTokenJson; + headers[csrfToken.header] = csrfToken.value; } - this.mockTiers.delete(id); + return sendRequest({ + abortController, + method: 'DELETE', + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'tiers', name]), + headers + }, false); } } diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index adcb248d5..d94cb74fc 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -273,4 +273,6 @@ export interface Tier { refillStrategy: RefillStrategy; } -export type TierFormData = Omit; +export interface TierList { + tiers: Tier[]; +} diff --git a/webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx b/webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx index 474216e82..ecc44dec2 100644 --- a/webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx +++ b/webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.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 React, { FC, useState } from 'react'; import { Dialog, diff --git a/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx b/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx index 4e31e97ba..75b4a56ea 100644 --- a/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx +++ b/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.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 React, { FC, useState, useEffect } from 'react'; import { Dialog, @@ -15,7 +28,7 @@ import { Box } from '@mui/material'; import type { SelectChangeEvent } from '@mui/material'; -import { RefillStrategy, type Tier, TierFormData } from "../../../extension-registry-types"; +import { RefillStrategy, type Tier } from "../../../extension-registry-types"; type DurationUnit = 'seconds' | 'minutes' | 'hours' | 'days'; @@ -30,17 +43,17 @@ interface TierFormDialogProps { open: boolean; tier?: Tier; onClose: () => void; - onSubmit: (formData: Omit) => Promise; + onSubmit: (formData: Tier) => Promise; } export const TierFormDialog: FC = ({ open, tier, onClose, onSubmit }) => { - const [formData, setFormData] = useState({ + const [formData, setFormData] = useState({ name: '', description: '', capacity: 100, duration: 3600, refillStrategy: RefillStrategy.INTERVAL - }); + } as Tier); const [durationValue, setDurationValue] = useState(1); const [durationUnit, setDurationUnit] = useState('hours'); const [loading, setLoading] = useState(false); @@ -58,7 +71,7 @@ export const TierFormDialog: FC = ({ open, tier, onClose, o capacity: tier.capacity, duration: tier.duration, refillStrategy: tier.refillStrategy as any - })); + } as Tier)); // Convert duration seconds to hours for display setDurationValue(Math.floor(tier.duration / 3600)); setDurationUnit('hours'); @@ -79,10 +92,10 @@ export const TierFormDialog: FC = ({ open, tier, onClose, o const handleChange = (e: React.ChangeEvent | SelectChangeEvent) => { const { name, value } = e.target as any; - setFormData((prev: Omit) => ({ + setFormData((prev: Tier) => ({ ...prev, [name]: name === 'capacity' || name === 'duration' ? Number.parseInt(value as string, 10) : value - } as Omit)); + } as Tier)); }; const handleSubmit = async () => { @@ -203,10 +216,10 @@ export const TierFormDialog: FC = ({ open, tier, onClose, o value={formData.refillStrategy || ''} onChange={(e: SelectChangeEvent) => { const { name, value } = e.target; - setFormData((prev: Omit) => ({ + setFormData((prev: Tier) => ({ ...prev, [name]: value - } as Omit)); + } as Tier)); }} label='Refill Strategy' > diff --git a/webui/src/pages/admin-dashboard/tiers/tiers.tsx b/webui/src/pages/admin-dashboard/tiers/tiers.tsx index f5cdc02c1..421960569 100644 --- a/webui/src/pages/admin-dashboard/tiers/tiers.tsx +++ b/webui/src/pages/admin-dashboard/tiers/tiers.tsx @@ -1,4 +1,17 @@ -import React, { FC, useState, useEffect } from "react"; +/* + * 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 React, { FC, useState, useEffect, useRef } from "react"; import { Box, Button, @@ -19,13 +32,20 @@ import EditIcon from "@mui/icons-material/Edit"; import DeleteIcon from "@mui/icons-material/Delete"; import AddIcon from "@mui/icons-material/Add"; import { MainContext } from "../../../context"; -import type { Tier, TierFormData } from "../../../extension-registry-types"; +import type { Tier } from "../../../extension-registry-types"; import { TierFormDialog } from "./tier-form-dialog"; import { DeleteTierDialog } from "./delete-tier-dialog"; export const Tiers: FC = () => { + const abortController = useRef(new AbortController()); + useEffect(() => { + return () => { + abortController.current.abort(); + }; + }, []); + const { service } = React.useContext(MainContext); - const [tiers, setTiers] = useState([]); + const [tiers, setTiers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [formDialogOpen, setFormDialogOpen] = useState(false); @@ -37,8 +57,8 @@ export const Tiers: FC = () => { try { setLoading(true); setError(null); - const data = await service.admin.getAllTiers(); - setTiers(data as Tier[]); + const data = await service.admin.getTiers(abortController.current); + setTiers(data.tiers); } catch (err: any) { setError(err.message || "Failed to load tiers"); } finally { @@ -65,14 +85,14 @@ export const Tiers: FC = () => { setDeleteDialogOpen(true); }; - const handleFormSubmit = async (formData: TierFormData) => { + const handleFormSubmit = async (formData: Tier) => { try { if (selectedTier) { // Update existing tier - await service.admin.updateTier(selectedTier.id, formData); + await service.admin.updateTier(abortController.current, selectedTier.name, formData); } else { // Create new tier - await service.admin.createTier(formData); + await service.admin.createTier(abortController.current, formData); } await loadTiers(); } catch (err: any) { @@ -83,7 +103,7 @@ export const Tiers: FC = () => { const handleDeleteConfirm = async () => { try { if (selectedTier) { - await service.admin.deleteTier(selectedTier.id); + await service.admin.deleteTier(abortController.current, selectedTier.name); await loadTiers(); } } catch (err: any) { @@ -117,27 +137,27 @@ export const Tiers: FC = () => {
- {error && ( + { error && setError(null)}> {error} - )} + } - {loading && ( + { loading && - )} + } - {!loading && tiers.length === 0 && ( + { !loading && tiers.length === 0 && No tiers found. Create one to get started. - )} + } - {!loading && tiers.length > 0 && ( + { !loading && tiers.length > 0 && @@ -189,7 +209,7 @@ export const Tiers: FC = () => {
- )} + } (req: ServerAPIRequest): Promise { +export async function sendRequest(req: ServerAPIRequest, retry: boolean = true): Promise { req.method ??= 'GET'; req.headers ??= {}; if (!req.headers['Accept']) { @@ -50,7 +50,7 @@ export async function sendRequest(req: ServerAPIRequest): Promise { param.credentials = 'include'; } - const options: any = { + const options: any = retry ? { retries: 10, retryDelay: (attempt: number, error: Error, response: Response) => { return Math.pow(2, attempt) * 1000; @@ -58,7 +58,7 @@ export async function sendRequest(req: ServerAPIRequest): Promise { retryOn: (attempt: number, error: Error, response: Response) => { return error !== null || response.status >= 500; } - }; + } : {}; const response = await fetchBuilder(fetch, options)(req.endpoint, param); if (response.ok) { From 7c4024e2e6be179fc8440b0ad1300e85795b53a1 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Thu, 22 Jan 2026 13:59:25 +0100 Subject: [PATCH 18/45] tier ui improvements --- .../eclipse/openvsx/admin/RateLimitAPI.java | 11 +++++-- .../repositories/CustomerRepository.java | 11 +++---- .../repositories/RepositoryService.java | 4 +++ .../tiers/delete-tier-dialog.tsx | 1 + .../tiers/tier-form-dialog.tsx | 30 ++++++++++++++----- .../src/pages/admin-dashboard/tiers/tiers.tsx | 6 ++-- 6 files changed, 42 insertions(+), 21 deletions(-) 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 8551126a0..7310c0b8b 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java @@ -113,12 +113,17 @@ public ResponseEntity deleteTier(@PathVariable String name) { try { admins.checkAdminUser(); - var savedTier = repositories.findTier(name); - if (savedTier == null) { + var tier = repositories.findTier(name); + if (tier == null) { return ResponseEntity.notFound().build(); } - repositories.deleteTier(savedTier); + var existingCustomers = repositories.countCustomersByTier(tier); + if (existingCustomers > 0) { + return ResponseEntity.badRequest().body(ResultJson.error("Cannot delete tier '" + name + "' because it is still in use")); + } + + repositories.deleteTier(tier); return ResponseEntity.ok(ResultJson.success("Deleted tier '" + name + "'")); } catch (Exception exc) { diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java index 701f39edf..23830f610 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java @@ -10,18 +10,15 @@ package org.eclipse.openvsx.repositories; import org.eclipse.openvsx.entities.Customer; +import org.eclipse.openvsx.entities.Tier; +import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.repository.Repository; import java.util.List; import java.util.Optional; -public interface CustomerRepository extends Repository { - - Optional findById(long id); - - List findAll(); - +public interface CustomerRepository extends JpaRepository { Optional findByNameIgnoreCase(String name); - long count(); + int countCustomersByTier(Tier tier); } 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 24d7fe9f1..049466508 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -679,6 +679,10 @@ public List findAllCustomers() { return customerRepo.findAll(); } + public int countCustomersByTier(Tier tier) { + return customerRepo.countCustomersByTier(tier); + } + public Optional findCustomerById(long id) { return customerRepo.findById(id); } diff --git a/webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx b/webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx index ecc44dec2..3144ffab1 100644 --- a/webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx +++ b/webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx @@ -43,6 +43,7 @@ export const DeleteTierDialog: FC = ({ open, tier, onClos onClose(); } catch (err: any) { setError(err.message || 'An error occurred while deleting the tier'); + } finally { setLoading(false); } }; diff --git a/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx b/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx index 75b4a56ea..ee5658dfe 100644 --- a/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx +++ b/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx @@ -30,15 +30,28 @@ import { import type { SelectChangeEvent } from '@mui/material'; import { RefillStrategy, type Tier } from "../../../extension-registry-types"; -type DurationUnit = 'seconds' | 'minutes' | 'hours' | 'days'; +type DurationUnit = 'seconds' | 'minutes' | 'hours'; const DURATION_MULTIPLIERS: Record = { seconds: 1, minutes: 60, - hours: 3600, - days: 86400 + hours: 3600 }; +function formatDuration(duration: number): [number, DurationUnit] { + const hours = Math.floor(duration / 3600); + if (hours > 0) { + return [hours, "hours"]; + } + + const minutes = Math.floor(duration / 60); + if (minutes > 0) { + return [minutes, "minutes"]; + } + + return [duration, "seconds"]; +} + interface TierFormDialogProps { open: boolean; tier?: Tier; @@ -65,16 +78,17 @@ export const TierFormDialog: FC = ({ open, tier, onClose, o useEffect(() => { if (tier) { - setFormData(prev => ({ + setFormData(_ => ({ name: tier.name, description: tier.description || '', capacity: tier.capacity, duration: tier.duration, refillStrategy: tier.refillStrategy as any } as Tier)); - // Convert duration seconds to hours for display - setDurationValue(Math.floor(tier.duration / 3600)); - setDurationUnit('hours'); + // Convert duration seconds to value/unit for display + const [value, unit] = formatDuration(tier.duration); + setDurationValue(value); + setDurationUnit(unit); } else { setFormData(prev => ({ ...prev, @@ -130,6 +144,7 @@ export const TierFormDialog: FC = ({ open, tier, onClose, o onClose(); } catch (err: any) { setError(err.message || 'An error occurred while saving the tier'); + } finally { setLoading(false); } }; @@ -201,7 +216,6 @@ export const TierFormDialog: FC = ({ open, tier, onClose, o Seconds Minutes Hours - Days diff --git a/webui/src/pages/admin-dashboard/tiers/tiers.tsx b/webui/src/pages/admin-dashboard/tiers/tiers.tsx index 421960569..e90d3eeed 100644 --- a/webui/src/pages/admin-dashboard/tiers/tiers.tsx +++ b/webui/src/pages/admin-dashboard/tiers/tiers.tsx @@ -52,7 +52,7 @@ export const Tiers: FC = () => { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [selectedTier, setSelectedTier] = useState(); - // Load all tiers + // load all tiers const loadTiers = async () => { try { setLoading(true); @@ -88,10 +88,10 @@ export const Tiers: FC = () => { const handleFormSubmit = async (formData: Tier) => { try { if (selectedTier) { - // Update existing tier + // update existing tier await service.admin.updateTier(abortController.current, selectedTier.name, formData); } else { - // Create new tier + // create new tier await service.admin.createTier(abortController.current, formData); } await loadTiers(); From 237655fccdef94a58f196fd592b4aa19affdced3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez=20Hidalgo?= <31970428+gnugomez@users.noreply.github.com> Date: Fri, 23 Jan 2026 08:40:40 +0100 Subject: [PATCH 19/45] feat: adding customers admin view (#1558) * feat: adding customers admin view * feat: updating tier types --- webui/src/extension-registry-service.ts | 38 ++- webui/src/extension-registry-types.ts | 13 +- .../pages/admin-dashboard/admin-dashboard.tsx | 5 + .../customers/customer-form-dialog.tsx | 200 ++++++++++++++++ .../admin-dashboard/customers/customers.tsx | 216 ++++++++++++++++++ .../customers/delete-customer-dialog.tsx | 79 +++++++ .../tiers/tier-form-dialog.tsx | 20 +- .../src/pages/admin-dashboard/tiers/tiers.tsx | 10 +- 8 files changed, 558 insertions(+), 23 deletions(-) create mode 100644 webui/src/pages/admin-dashboard/customers/customer-form-dialog.tsx create mode 100644 webui/src/pages/admin-dashboard/customers/customers.tsx create mode 100644 webui/src/pages/admin-dashboard/customers/delete-customer-dialog.tsx diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index c088a7e11..f24d186d0 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -12,7 +12,7 @@ import { Extension, UserData, ExtensionCategory, ExtensionReviewList, PersonalAccessToken, SearchResult, NewReview, SuccessResult, ErrorResult, CsrfTokenJson, isError, Namespace, NamespaceDetails, MembershipRole, SortBy, SortOrder, UrlString, NamespaceMembershipList, PublisherInfo, SearchEntry, RegistryVersion, - LoginProviders, Tier, TierList + LoginProviders, Tier, TierList, Customer, CustomerState, } from './extension-registry-types'; import { createAbsoluteURL, addQuery } from './utils'; import { sendRequest, ErrorResponse } from './server-request'; @@ -490,6 +490,10 @@ export interface AdminService { createTier(abortController: AbortController, tier: Tier): Promise>; updateTier(abortController: AbortController, name: string, tier: Tier): Promise>; deleteTier(abortController: AbortController, name: string): Promise>; + getCustomers(): Promise>; + createCustomer(formData: Customer): Promise>; + updateCustomer(name: string, formData: Customer): Promise>; + deleteCustomer(name: string): Promise>; } export type AdminServiceConstructor = new (registry: ExtensionRegistryService) => AdminService; @@ -675,6 +679,38 @@ export class AdminServiceImpl implements AdminService { headers }, false); } + + async getCustomers(): Promise> { + return [ + { + name: 'Acme Corp', + state: CustomerState.ENFORCEMENT, + cidrBlocks: '192.168.1.0/24,192.168.2.0/24' + }, + { + name: 'TechStart Inc', + state: CustomerState.ENFORCEMENT, + cidrBlocks: '10.0.0.0/8' + }, + { + name: 'Legacy Systems Ltd', + state: CustomerState.EVALUATION, + cidrBlocks: undefined + } + ]; + } + + async createCustomer(formData: Customer): Promise> { + return formData; + } + + async updateCustomer(name: string, formData: Customer): Promise> { + return formData; + } + + async deleteCustomer(name: string): Promise { + // No-op for mock + } } export interface ExtensionFilter { diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index d94cb74fc..acf86db0e 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -265,7 +265,6 @@ export enum RefillStrategy { } export interface Tier { - id: number; name: string; description?: string; capacity: number; @@ -276,3 +275,15 @@ export interface Tier { export interface TierList { tiers: Tier[]; } + +export enum CustomerState { + ENFORCEMENT = 'ENFORCEMENT', + EVALUATION = 'EVALUATION' +} + +export interface Customer { + name: string; + tierId?: string; + state: CustomerState; + cidrBlocks?: string; +} diff --git a/webui/src/pages/admin-dashboard/admin-dashboard.tsx b/webui/src/pages/admin-dashboard/admin-dashboard.tsx index 8f1b2ed3c..811ddbb10 100644 --- a/webui/src/pages/admin-dashboard/admin-dashboard.tsx +++ b/webui/src/pages/admin-dashboard/admin-dashboard.tsx @@ -23,8 +23,10 @@ import HighlightOffIcon from '@mui/icons-material/HighlightOff'; import { Welcome } from './welcome'; import { PublisherAdmin } from './publisher-admin'; import PersonIcon from '@mui/icons-material/Person'; +import PeopleIcon from '@mui/icons-material/People'; import StarIcon from '@mui/icons-material/Star'; import { Tiers } from './tiers/tiers'; +import { Customers } from './customers/customers'; export namespace AdminDashboardRoutes { export const ROOT = 'admin-dashboard'; @@ -33,6 +35,7 @@ export namespace AdminDashboardRoutes { export const EXTENSION_ADMIN = createRoute([ROOT, 'extensions']); export const PUBLISHER_ADMIN = createRoute([ROOT, 'publisher']); export const TIERS = createRoute([ROOT, 'tiers']); + export const CUSTOMERS = createRoute([ROOT, 'customers']); } const Message: FunctionComponent<{message: string}> = ({ message }) => { @@ -63,6 +66,7 @@ export const AdminDashboard: FunctionComponent = props => { } route={AdminDashboardRoutes.EXTENSION_ADMIN} /> } route={AdminDashboardRoutes.PUBLISHER_ADMIN} /> } route={AdminDashboardRoutes.TIERS} /> + } route={AdminDashboardRoutes.CUSTOMERS} /> @@ -74,6 +78,7 @@ export const AdminDashboard: FunctionComponent = props => { } /> } /> } /> + } /> } /> diff --git a/webui/src/pages/admin-dashboard/customers/customer-form-dialog.tsx b/webui/src/pages/admin-dashboard/customers/customer-form-dialog.tsx new file mode 100644 index 000000000..836853868 --- /dev/null +++ b/webui/src/pages/admin-dashboard/customers/customer-form-dialog.tsx @@ -0,0 +1,200 @@ +import React, { FC, useState, useEffect, useRef } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + Button, + FormControl, + InputLabel, + Select, + MenuItem, + CircularProgress, + Alert, + Box +} from '@mui/material'; +import type { SelectChangeEvent } from '@mui/material'; +import { CustomerState, type Customer, type Tier } from "../../../extension-registry-types"; +import { MainContext } from "../../../context"; + +interface CustomerFormDialogProps { + open: boolean; + customer?: Customer; + onClose: () => void; + onSubmit: (formData: Customer) => Promise; +} + +export const CustomerFormDialog: FC = ({ open, customer, onClose, onSubmit }) => { + const abortController = useRef(); + const { service } = React.useContext(MainContext); + const [formData, setFormData] = useState({ + name: '', + tierId: undefined, + state: CustomerState.ENFORCEMENT, + cidrBlocks: undefined + }); + const [tiers, setTiers] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + abortController.current = new AbortController(); + + const loadTiers = async () => { + try { + const data = await service.admin.getTiers(abortController.current!); + setTiers(data.tiers); + } catch (err: any) { + console.error('Failed to load tiers:', err); + } + }; + + loadTiers(); + + return () => abortController.current?.abort(); + }, [service]); + + useEffect(() => { + if (customer) { + setFormData({ + name: customer.name, + tierId: customer.tierId, + state: customer.state, + cidrBlocks: customer.cidrBlocks + }); + } else { + setFormData({ + name: '', + tierId: undefined, + state: CustomerState.ENFORCEMENT, + cidrBlocks: undefined + }); + } + setError(null); + }, [open, customer]); + + const handleChange = (e: React.ChangeEvent | SelectChangeEvent) => { + const { name, value } = e.target; + + if (name === 'tierId') { + setFormData(prev => ({ + ...prev, + tierId: value === '' ? undefined : (value), + })); + } else { + setFormData(prev => ({ ...prev, [name]: value })); + } + }; + + const handleSubmit = async () => { + setError(null); + setLoading(true); + + try { + // Validate required fields + if (!formData.name.trim()) { + throw new Error('Customer name is required'); + } + + if (!formData.state) { + throw new Error('State is required'); + } + + await onSubmit(formData); + onClose(); + } catch (err: any) { + setError(err.message || 'An error occurred while saving the customer'); + } finally { + setLoading(false); + } + }; + + const isEditMode = !!customer; + const title = isEditMode ? 'Edit Customer' : 'Create New Customer'; + + return ( + + {title} + + + {error && {error}} + + + + + Tier + + + + + State + + + + + + + + + + + + + + ); +}; diff --git a/webui/src/pages/admin-dashboard/customers/customers.tsx b/webui/src/pages/admin-dashboard/customers/customers.tsx new file mode 100644 index 000000000..8281d820d --- /dev/null +++ b/webui/src/pages/admin-dashboard/customers/customers.tsx @@ -0,0 +1,216 @@ +import React, { FC, useState, useEffect, useRef, useCallback } from "react"; +import { + Box, + Button, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, + CircularProgress, + Alert, + IconButton, + Stack, + Chip +} from "@mui/material"; +import EditIcon from "@mui/icons-material/Edit"; +import DeleteIcon from "@mui/icons-material/Delete"; +import AddIcon from "@mui/icons-material/Add"; +import { MainContext } from "../../../context"; +import type { Customer } from "../../../extension-registry-types"; +import { CustomerFormDialog } from "./customer-form-dialog"; +import { DeleteCustomerDialog } from "./delete-customer-dialog"; + +export const Customers: FC = () => { + const abortController = useRef(); + const { service } = React.useContext(MainContext); + const [customers, setCustomers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [formDialogOpen, setFormDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [selectedCustomer, setSelectedCustomer] = useState(); + + useEffect(() => { + abortController.current = new AbortController(); + return () => abortController.current?.abort(); + }, []); + + // Load all customers + const loadCustomers = useCallback(async () => { + try { + setLoading(true); + setError(null); + const data = await service.admin.getCustomers(); + setCustomers(data as Customer[]); + } catch (err: any) { + setError(err.message || "Failed to load customers"); + } finally { + setLoading(false); + } + }, [service]); + + useEffect(() => { + loadCustomers(); + }, [loadCustomers]); + + const handleCreateClick = () => { + setSelectedCustomer(undefined); + setFormDialogOpen(true); + }; + + const handleEditClick = (customer: Customer) => { + setSelectedCustomer(customer); + setFormDialogOpen(true); + }; + + const handleDeleteClick = (customer: Customer) => { + setSelectedCustomer(customer); + setDeleteDialogOpen(true); + }; + + const handleFormSubmit = async (formData: Customer) => { + try { + if (selectedCustomer) { + // Update existing customer + await service.admin.updateCustomer(selectedCustomer.name, formData); + } else { + // Create new customer + await service.admin.createCustomer(formData); + } + await loadCustomers(); + } catch (err: any) { + throw new Error(err.message || "Failed to save customer"); + } + }; + + const handleDeleteConfirm = async () => { + try { + if (selectedCustomer) { + await service.admin.deleteCustomer(selectedCustomer.name); + await loadCustomers(); + } + } catch (err: any) { + throw new Error(err.message || "Failed to delete customer"); + } + }; + + const handleFormDialogClose = () => { + setFormDialogOpen(false); + setSelectedCustomer(undefined); + }; + + const handleDeleteDialogClose = () => { + setDeleteDialogOpen(false); + setSelectedCustomer(undefined); + }; + + return ( + + + + Customers Management + + + + + {error && ( + setError(null)}> + {error} + + )} + + {loading && ( + + + + )} + + {!loading && customers.length === 0 && ( + + + No customers found. Create one to get started. + + + )} + + {!loading && customers.length > 0 && ( + + + + + Name + Tier + State + CIDR Blocks + + Actions + + + + + {customers.map(customer => ( + + {customer.name} + {customer.tierId || "-"} + + + + {customer.cidrBlocks || "-"} + + + handleEditClick(customer)} + title='Edit customer' + color='primary' + > + + + handleDeleteClick(customer)} + title='Delete customer' + color='error' + > + + + + + + ))} + +
+
+ )} + + + + +
+ ); +}; diff --git a/webui/src/pages/admin-dashboard/customers/delete-customer-dialog.tsx b/webui/src/pages/admin-dashboard/customers/delete-customer-dialog.tsx new file mode 100644 index 000000000..54ea367ff --- /dev/null +++ b/webui/src/pages/admin-dashboard/customers/delete-customer-dialog.tsx @@ -0,0 +1,79 @@ +import React, { FC, useState } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, + Alert, + CircularProgress, + Box +} from '@mui/material'; +import WarningIcon from '@mui/icons-material/Warning'; +import type { Customer } from "../../../extension-registry-types"; + +interface DeleteCustomerDialogProps { + open: boolean; + customer?: Customer; + onClose: () => void; + onConfirm: () => Promise; +} + +export const DeleteCustomerDialog: FC = ({ open, customer, onClose, onConfirm }) => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleConfirm = async () => { + try { + setError(null); + setLoading(true); + await onConfirm(); + onClose(); + } catch (err: any) { + setError(err.message || 'An error occurred while deleting the customer'); + setLoading(false); + } + }; + + if (!customer) return null; + + return ( + + + + + Delete Customer + + + + {error && {error}} + + Are you sure you want to delete the customer {customer.name}? + + {customer.tierName && ( + + This customer is currently using the {customer.tierName} tier. + + )} + + This action cannot be undone. All associated usage statistics will also be deleted. + + + + + + + + ); +}; diff --git a/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx b/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx index ee5658dfe..f206e91e3 100644 --- a/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx +++ b/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx @@ -113,27 +113,21 @@ export const TierFormDialog: FC = ({ open, tier, onClose, o }; const handleSubmit = async () => { - try { - setError(null); - setLoading(true); + setError(null); + setLoading(true); - // Basic validation + try { + // Validate required fields if (!formData.name.trim()) { - setError('Tier name is required'); - setLoading(false); - return; + throw new Error('Tier name is required'); } if (formData.capacity <= 0) { - setError('Capacity must be greater than 0'); - setLoading(false); - return; + throw new Error('Capacity must be greater than 0'); } if (durationValue <= 0) { - setError('Duration must be greater than 0'); - setLoading(false); - return; + throw new Error('Duration must be greater than 0'); } const durationInSeconds = getDurationInSeconds(); diff --git a/webui/src/pages/admin-dashboard/tiers/tiers.tsx b/webui/src/pages/admin-dashboard/tiers/tiers.tsx index e90d3eeed..9b4a3ab09 100644 --- a/webui/src/pages/admin-dashboard/tiers/tiers.tsx +++ b/webui/src/pages/admin-dashboard/tiers/tiers.tsx @@ -38,12 +38,6 @@ import { DeleteTierDialog } from "./delete-tier-dialog"; export const Tiers: FC = () => { const abortController = useRef(new AbortController()); - useEffect(() => { - return () => { - abortController.current.abort(); - }; - }, []); - const { service } = React.useContext(MainContext); const [tiers, setTiers] = useState([]); const [loading, setLoading] = useState(true); @@ -68,8 +62,8 @@ export const Tiers: FC = () => { useEffect(() => { loadTiers(); + return () => abortController.current?.abort(); }, []); - const handleCreateClick = () => { setSelectedTier(undefined); setFormDialogOpen(true); @@ -178,7 +172,7 @@ export const Tiers: FC = () => { {tiers.map(tier => ( - + {tier.name} {tier.description || "-"} {tier.capacity.toLocaleString()} From 8edd8fdd25890906333b27c5a32fc154082ea492 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Fri, 23 Jan 2026 11:08:33 +0100 Subject: [PATCH 20/45] add endpoints for customer management --- .../eclipse/openvsx/admin/RateLimitAPI.java | 133 +++++++++++++++++- .../eclipse/openvsx/entities/Customer.java | 24 ++++ .../eclipse/openvsx/json/CustomerJson.java | 25 ++++ .../openvsx/json/CustomerListJson.java | 22 +++ .../repositories/CustomerRepository.java | 2 +- .../repositories/RepositoryService.java | 10 +- webui/src/extension-registry-service.ts | 90 ++++++++---- webui/src/extension-registry-types.ts | 16 ++- .../customers/customer-form-dialog.tsx | 84 ++++++----- .../admin-dashboard/customers/customers.tsx | 62 +++++--- .../customers/delete-customer-dialog.tsx | 17 ++- .../src/pages/admin-dashboard/tiers/tiers.tsx | 5 +- webui/src/pages/admin-dashboard/welcome.tsx | 2 + 13 files changed, 385 insertions(+), 107 deletions(-) create mode 100644 server/src/main/java/org/eclipse/openvsx/json/CustomerJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/CustomerListJson.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 7310c0b8b..6728bd1a8 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java @@ -12,10 +12,9 @@ */ package org.eclipse.openvsx.admin; +import org.eclipse.openvsx.entities.Customer; import org.eclipse.openvsx.entities.Tier; -import org.eclipse.openvsx.json.ResultJson; -import org.eclipse.openvsx.json.TierJson; -import org.eclipse.openvsx.json.TierListJson; +import org.eclipse.openvsx.json.*; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.util.ErrorResultException; import org.slf4j.Logger; @@ -66,7 +65,7 @@ public ResponseEntity getTiers() { ) public ResponseEntity createTier(@RequestBody TierJson tier) { try { - admins.checkAdminUser(); + var adminUser = admins.checkAdminUser(); var existingTier = repositories.findTier(tier.name()); if (existingTier != null) { @@ -74,6 +73,10 @@ public ResponseEntity createTier(@RequestBody TierJson tier) { } var savedTier = repositories.upsertTier(Tier.fromJson(tier)); + + var result = ResultJson.success("Created tier '" + savedTier.getName() + "'"); + admins.logAdminAction(adminUser, result); + return ResponseEntity.ok(savedTier.toJson()); } catch (Exception exc) { logger.error("failed creating tier {}", tier.name(), exc); @@ -88,7 +91,7 @@ public ResponseEntity createTier(@RequestBody TierJson tier) { ) public ResponseEntity updateTier(@PathVariable String name, @RequestBody TierJson tier) { try { - admins.checkAdminUser(); + var adminUser = admins.checkAdminUser(); var savedTier = repositories.findTier(name); if (savedTier == null) { @@ -98,6 +101,9 @@ public ResponseEntity updateTier(@PathVariable String name, @RequestBo savedTier.updateFromJson(tier); savedTier = repositories.upsertTier(savedTier); + var result = ResultJson.success("Updated tier '" + savedTier.getName() + "'"); + admins.logAdminAction(adminUser, result); + return ResponseEntity.ok(savedTier.toJson()); } catch (Exception exc) { logger.error("failed updating tier {}", name, exc); @@ -111,7 +117,7 @@ public ResponseEntity updateTier(@PathVariable String name, @RequestBo ) public ResponseEntity deleteTier(@PathVariable String name) { try { - admins.checkAdminUser(); + var adminUser = admins.checkAdminUser(); var tier = repositories.findTier(name); if (tier == null) { @@ -125,10 +131,123 @@ public ResponseEntity deleteTier(@PathVariable String name) { repositories.deleteTier(tier); - return ResponseEntity.ok(ResultJson.success("Deleted tier '" + name + "'")); + var result = ResultJson.success("Deleted tier '" + name + "'"); + admins.logAdminAction(adminUser, result); + + return ResponseEntity.ok(result); } catch (Exception exc) { logger.error("failed deleting tier {}", name, exc); return ResponseEntity.internalServerError().build(); } } + + @GetMapping( + path = "/customers", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity getCustomers() { + try { + admins.checkAdminUser(); + + var customers = repositories.findAllCustomers(); + var result = new CustomerListJson(customers.stream().map(Customer::toJson).toList()); + return ResponseEntity.ok(result); + } catch (Exception exc) { + logger.error("failed retrieving customers", exc); + return ResponseEntity.internalServerError().build(); + } + } + + @PostMapping( + path = "/customers/create", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity createCustomer(@RequestBody CustomerJson customerJson) { + try { + var adminUser = admins.checkAdminUser(); + + var existingCustomer = repositories.findCustomer(customerJson.name()); + if (existingCustomer != null) { + return ResponseEntity.badRequest().build(); + } + + var customer = Customer.fromJson(customerJson); + // resolve the tier reference + var tier = repositories.findTier(customer.getTier().getName()); + if (tier == null) { + return ResponseEntity.badRequest().build(); + } + customer.setTier(tier); + + var savedCustomer = repositories.upsertCustomer(customer); + + var result = ResultJson.success("Created customer '" + savedCustomer.getName() + "'"); + admins.logAdminAction(adminUser, result); + + return ResponseEntity.ok(savedCustomer.toJson()); + } catch (Exception exc) { + logger.error("failed creating customer {}", customerJson.name(), exc); + return ResponseEntity.internalServerError().build(); + } + } + + @PutMapping( + path = "/customers/{name}", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity updateCustomer(@PathVariable String name, @RequestBody CustomerJson customer) { + try { + var adminUser = admins.checkAdminUser(); + + var savedCustomer = repositories.findCustomer(name); + if (savedCustomer == null) { + return ResponseEntity.notFound().build(); + } + + savedCustomer.updateFromJson(customer); + // update the tier reference in case it changed + var tier = repositories.findTier(savedCustomer.getTier().getName()); + if (tier == null) { + return ResponseEntity.badRequest().build(); + } + savedCustomer.setTier(tier); + + savedCustomer = repositories.upsertCustomer(savedCustomer); + + var result = ResultJson.success("Updated customer '" + savedCustomer.getName() + "'"); + admins.logAdminAction(adminUser, result); + + return ResponseEntity.ok(savedCustomer.toJson()); + } catch (Exception exc) { + logger.error("failed updating tier {}", name, exc); + return ResponseEntity.internalServerError().build(); + } + } + + @DeleteMapping( + path = "/customers/{name}", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity deleteCustomer(@PathVariable String name) { + try { + var adminUser = admins.checkAdminUser(); + + var customer = repositories.findCustomer(name); + if (customer == null) { + return ResponseEntity.notFound().build(); + } + + repositories.deleteCustomer(customer); + + var result = ResultJson.success("Deleted customer '" + name + "'"); + admins.logAdminAction(adminUser, result); + + return ResponseEntity.ok(result); + } catch (Exception exc) { + logger.error("failed deleting customer {}", name, exc); + return ResponseEntity.internalServerError().build(); + } + } } 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 1f4f2a7e6..c6e0e60ea 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/Customer.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/Customer.java @@ -13,9 +13,12 @@ package org.eclipse.openvsx.entities; import jakarta.persistence.*; +import org.eclipse.openvsx.json.CustomerJson; +import org.eclipse.openvsx.json.TierJson; import java.io.Serial; import java.io.Serializable; +import java.time.Duration; import java.util.Collections; import java.util.List; import java.util.Objects; @@ -87,6 +90,27 @@ public void setCidrBlocks(List cidrBlocks) { this.cidrBlocks = cidrBlocks; } + public CustomerJson toJson() { + return new CustomerJson(name, tier.toJson(), state.name(), cidrBlocks); + } + + public Customer updateFromJson(CustomerJson json) { + setName(json.name()); + setTier(Tier.fromJson(json.tier())); + setState(EnforcementState.valueOf(json.state())); + setCidrBlocks(json.cidrBlocks()); + return this; + } + + public static Customer fromJson(CustomerJson json) { + var customer = new Customer(); + customer.setName(json.name()); + customer.setTier(Tier.fromJson(json.tier())); + customer.setState(EnforcementState.valueOf(json.state())); + customer.setCidrBlocks(json.cidrBlocks()); + return customer; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/server/src/main/java/org/eclipse/openvsx/json/CustomerJson.java b/server/src/main/java/org/eclipse/openvsx/json/CustomerJson.java new file mode 100644 index 000000000..051b64634 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/CustomerJson.java @@ -0,0 +1,25 @@ +/* + * 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 java.util.List; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record CustomerJson( + String name, + TierJson tier, + String state, + List cidrBlocks +) {} diff --git a/server/src/main/java/org/eclipse/openvsx/json/CustomerListJson.java b/server/src/main/java/org/eclipse/openvsx/json/CustomerListJson.java new file mode 100644 index 000000000..2b6e60ec3 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/CustomerListJson.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 java.util.List; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record CustomerListJson( + List customers +) {} diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java index 23830f610..fe958cc36 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java @@ -18,7 +18,7 @@ import java.util.Optional; public interface CustomerRepository extends JpaRepository { - Optional findByNameIgnoreCase(String name); + Customer findByNameIgnoreCase(String name); int countCustomersByTier(Tier tier); } 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 049466508..389926556 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -687,12 +687,16 @@ public Optional findCustomerById(long id) { return customerRepo.findById(id); } - public Optional findCustomer(String name) { + public Customer findCustomer(String name) { return customerRepo.findByNameIgnoreCase(name); } - public long countCustomers() { - return customerRepo.count(); + public Customer upsertCustomer(Customer customer) { + return customerRepo.save(customer); + } + + public void deleteCustomer(Customer customer) { + customerRepo.delete(customer); } public List findAllTiers() { diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index f24d186d0..cec6b7a6f 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -12,7 +12,7 @@ import { Extension, UserData, ExtensionCategory, ExtensionReviewList, PersonalAccessToken, SearchResult, NewReview, SuccessResult, ErrorResult, CsrfTokenJson, isError, Namespace, NamespaceDetails, MembershipRole, SortBy, SortOrder, UrlString, NamespaceMembershipList, PublisherInfo, SearchEntry, RegistryVersion, - LoginProviders, Tier, TierList, Customer, CustomerState, + LoginProviders, Tier, TierList, Customer, CustomerList, } from './extension-registry-types'; import { createAbsoluteURL, addQuery } from './utils'; import { sendRequest, ErrorResponse } from './server-request'; @@ -490,10 +490,10 @@ export interface AdminService { createTier(abortController: AbortController, tier: Tier): Promise>; updateTier(abortController: AbortController, name: string, tier: Tier): Promise>; deleteTier(abortController: AbortController, name: string): Promise>; - getCustomers(): Promise>; - createCustomer(formData: Customer): Promise>; - updateCustomer(name: string, formData: Customer): Promise>; - deleteCustomer(name: string): Promise>; + getCustomers(abortController: AbortController): Promise>; + createCustomer(abortController: AbortController, customer: Customer): Promise>; + updateCustomer(abortController: AbortController, name: string, customer: Customer): Promise>; + deleteCustomer(abortController: AbortController, name: string): Promise>; } export type AdminServiceConstructor = new (registry: ExtensionRegistryService) => AdminService; @@ -680,36 +680,68 @@ export class AdminServiceImpl implements AdminService { }, false); } - async getCustomers(): Promise> { - return [ - { - name: 'Acme Corp', - state: CustomerState.ENFORCEMENT, - cidrBlocks: '192.168.1.0/24,192.168.2.0/24' - }, - { - name: 'TechStart Inc', - state: CustomerState.ENFORCEMENT, - cidrBlocks: '10.0.0.0/8' - }, - { - name: 'Legacy Systems Ltd', - state: CustomerState.EVALUATION, - cidrBlocks: undefined - } - ]; + async getCustomers(abortController: AbortController): Promise> { + return sendRequest({ + abortController, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'customers']), + credentials: true + }); } - async createCustomer(formData: Customer): Promise> { - return formData; + async createCustomer(abortController: AbortController, customer: Customer): Promise> { + const csrfResponse = await this.registry.getCsrfToken(abortController); + const headers: Record = { + 'Content-Type': 'application/json;charset=UTF-8' + }; + if (!isError(csrfResponse)) { + const csrfToken = csrfResponse as CsrfTokenJson; + headers[csrfToken.header] = csrfToken.value; + } + return sendRequest({ + abortController, + method: 'POST', + payload: customer, + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'customers', 'create']), + headers + }, false); } - async updateCustomer(name: string, formData: Customer): Promise> { - return formData; + async updateCustomer(abortController: AbortController, name: string, customer: Customer): Promise> { + const csrfResponse = await this.registry.getCsrfToken(abortController); + const headers: Record = { + 'Content-Type': 'application/json;charset=UTF-8' + }; + if (!isError(csrfResponse)) { + const csrfToken = csrfResponse as CsrfTokenJson; + headers[csrfToken.header] = csrfToken.value; + } + return sendRequest({ + abortController, + method: 'PUT', + payload: customer, + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'customers', name]), + headers + }, false); } - async deleteCustomer(name: string): Promise { - // No-op for mock + async deleteCustomer(abortController: AbortController, name: string): Promise { + const csrfResponse = await this.registry.getCsrfToken(abortController); + const headers: Record = { + 'Content-Type': 'application/json;charset=UTF-8' + }; + if (!isError(csrfResponse)) { + const csrfToken = csrfResponse as CsrfTokenJson; + headers[csrfToken.header] = csrfToken.value; + } + return sendRequest({ + abortController, + method: 'DELETE', + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'customers', name]), + headers + }, false); } } diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index acf86db0e..57ecd2b63 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -276,14 +276,18 @@ export interface TierList { tiers: Tier[]; } -export enum CustomerState { - ENFORCEMENT = 'ENFORCEMENT', - EVALUATION = 'EVALUATION' +export enum EnforcementState { + EVALUATION = 'EVALUATION', + ENFORCEMENT = 'ENFORCEMENT' } export interface Customer { name: string; - tierId?: string; - state: CustomerState; - cidrBlocks?: string; + tier?: Tier; + state: EnforcementState; + cidrBlocks: string[]; +} + +export interface CustomerList { + customers: Customer[]; } 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 836853868..13f14e184 100644 --- a/webui/src/pages/admin-dashboard/customers/customer-form-dialog.tsx +++ b/webui/src/pages/admin-dashboard/customers/customer-form-dialog.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 React, { FC, useState, useEffect, useRef } from 'react'; import { Dialog, @@ -15,7 +28,7 @@ import { Box } from '@mui/material'; import type { SelectChangeEvent } from '@mui/material'; -import { CustomerState, type Customer, type Tier } from "../../../extension-registry-types"; +import { type Customer, EnforcementState, type Tier } from "../../../extension-registry-types"; import { MainContext } from "../../../context"; interface CustomerFormDialogProps { @@ -26,49 +39,46 @@ interface CustomerFormDialogProps { } export const CustomerFormDialog: FC = ({ open, customer, onClose, onSubmit }) => { - const abortController = useRef(); + const abortController = useRef(new AbortController()); const { service } = React.useContext(MainContext); const [formData, setFormData] = useState({ name: '', - tierId: undefined, - state: CustomerState.ENFORCEMENT, - cidrBlocks: undefined + tier: undefined, + state: EnforcementState.ENFORCEMENT, + cidrBlocks: [] }); const [tiers, setTiers] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const loadTiers = async () => { + try { + const data = await service.admin.getTiers(abortController.current!); + setTiers(data.tiers); + } catch (err: any) { + console.error('Failed to load tiers:', err); + } + }; + useEffect(() => { - abortController.current = new AbortController(); - - const loadTiers = async () => { - try { - const data = await service.admin.getTiers(abortController.current!); - setTiers(data.tiers); - } catch (err: any) { - console.error('Failed to load tiers:', err); - } - }; - loadTiers(); - - return () => abortController.current?.abort(); - }, [service]); + return () => abortController.current.abort(); + }, []); useEffect(() => { if (customer) { setFormData({ name: customer.name, - tierId: customer.tierId, + tier: customer.tier, state: customer.state, cidrBlocks: customer.cidrBlocks }); } else { setFormData({ name: '', - tierId: undefined, - state: CustomerState.ENFORCEMENT, - cidrBlocks: undefined + tier: undefined, + state: EnforcementState.ENFORCEMENT, + cidrBlocks: [] }); } setError(null); @@ -76,11 +86,18 @@ export const CustomerFormDialog: FC = ({ open, customer const handleChange = (e: React.ChangeEvent | SelectChangeEvent) => { const { name, value } = e.target; - - if (name === 'tierId') { + + if (name === 'tierName') { + const tier = tiers.find((tier) => tier.name === value); + setFormData(prev => ({ + ...prev, + tier: tier, + })); + } else if (name === 'cidrBlocks') { + const cidrsBlocks = value.split(",") setFormData(prev => ({ ...prev, - tierId: value === '' ? undefined : (value), + cidrBlocks: cidrsBlocks, })); } else { setFormData(prev => ({ ...prev, [name]: value })); @@ -92,7 +109,7 @@ export const CustomerFormDialog: FC = ({ open, customer setLoading(true); try { - // Validate required fields + // validate required fields if (!formData.name.trim()) { throw new Error('Customer name is required'); } @@ -134,14 +151,11 @@ export const CustomerFormDialog: FC = ({ open, customer Tier { + setTouched(prev => ({ ...prev, tierName: true })); + validateField('tierName'); + }} label='Tier' > {tiers.map(tier => ( @@ -162,14 +278,19 @@ export const CustomerFormDialog: FC = ({ open, customer ))} + {touched.tierName && errors.tierName && {errors.tierName}} - + State + {touched.state && errors.state && {errors.state}} - { + setTouched(prev => ({ ...prev, cidrBlocks: true })); + validateField('cidrBlocks'); + }} + renderInput={(params) => ( + Enter CIDR blocks and press Enter to add each one + )} + /> + )} />
@@ -203,7 +337,7 @@ export const CustomerFormDialog: FC = ({ open, customer - - - { error && ( - setError(null)}> - {error} - - )} - - { loading && ( - - + }; + + const handleDeleteConfirm = async () => { + if (selectedCustomer) { + await service.admin.deleteCustomer(abortController.current, selectedCustomer.name); + await loadCustomers(); + } + }; + + const handleFormDialogClose = () => { + setFormDialogOpen(false); + setSelectedCustomer(undefined); + }; + + const handleDeleteDialogClose = () => { + setDeleteDialogOpen(false); + setSelectedCustomer(undefined); + }; + + return ( + + + + Customer Management + + + + + {error && ( + setError(null)}> + {error} + + )} + + {loading && ( + + + + )} + + {!loading && customers.length === 0 && ( + + + No customers found. Create one to get started. + + + )} + + {!loading && customers.length > 0 && ( + + + + + Name + Tier + State + CIDR Blocks + + Actions + + + + + {customers.map(customer => ( + + {customer.name} + {customer.tier?.name || "-"} + + + + + {customer.cidrBlocks.length > 0 + ? customer.cidrBlocks.map((value) => ( + + )) + : "-" + } + + + + handleEditClick(customer)} + title='Edit customer' + color='primary' + > + + + handleDeleteClick(customer)} + title='Delete customer' + color='error' + > + + + + + + ))} + +
+
+ )} + + + +
- )} - - { !loading && customers.length === 0 && ( - - - No customers found. Create one to get started. - - - )} - - { !loading && customers.length > 0 && ( - - - - - Name - Tier - State - CIDR Blocks - - Actions - - - - - {customers.map(customer => ( - - {customer.name} - {customer.tier?.name || "-"} - - - - - {customer.cidrBlocks.length > 0 - ? customer.cidrBlocks.map((value) => ( - - )) - : "-" - } - - - - handleEditClick(customer)} - title='Edit customer' - color='primary' - > - - - handleDeleteClick(customer)} - title='Delete customer' - color='error' - > - - - - - - ))} - -
-
- )} - - - - -
- ); + ); }; diff --git a/webui/src/pages/admin-dashboard/customers/delete-customer-dialog.tsx b/webui/src/pages/admin-dashboard/customers/delete-customer-dialog.tsx index 3ffc2f143..6cfd70352 100644 --- a/webui/src/pages/admin-dashboard/customers/delete-customer-dialog.tsx +++ b/webui/src/pages/admin-dashboard/customers/delete-customer-dialog.tsx @@ -25,6 +25,7 @@ import { } from '@mui/material'; import WarningIcon from '@mui/icons-material/Warning'; import type { Customer } from "../../../extension-registry-types"; +import {handleError} from "../../../utils"; interface DeleteCustomerDialogProps { open: boolean; @@ -44,7 +45,7 @@ export const DeleteCustomerDialog: FC = ({ open, cust await onConfirm(); onClose(); } catch (err: any) { - setError(err.message || 'An error occurred while deleting the customer'); + setError(handleError(err)); setLoading(false); } }; diff --git a/webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx b/webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx index 3144ffab1..7e7074dc9 100644 --- a/webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx +++ b/webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx @@ -23,6 +23,7 @@ import { Alert } from '@mui/material'; import type { Tier } from '../../../extension-registry-types'; +import {handleError} from "../../../utils"; interface DeleteTierDialogProps { open: boolean; @@ -42,7 +43,7 @@ export const DeleteTierDialog: FC = ({ open, tier, onClos await onConfirm(); onClose(); } catch (err: any) { - setError(err.message || 'An error occurred while deleting the tier'); + setError(handleError(err)); } finally { setLoading(false); } diff --git a/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx b/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx index bbe985dba..3b80e3c63 100644 --- a/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx +++ b/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx @@ -30,6 +30,7 @@ import { } from '@mui/material'; import type { SelectChangeEvent } from '@mui/material'; import { RefillStrategy, type Tier } from "../../../extension-registry-types"; +import {handleError} from "../../../utils"; type DurationUnit = 'seconds' | 'minutes' | 'hours'; @@ -196,7 +197,7 @@ export const TierFormDialog: FC = ({ open, tier, onClose, o }); onClose(); } catch (err: any) { - setErrors({ submit: err.message || 'An error occurred while saving the tier' }); + setErrors({ submit: handleError(err) }); } finally { setLoading(false); } diff --git a/webui/src/pages/admin-dashboard/tiers/tiers.tsx b/webui/src/pages/admin-dashboard/tiers/tiers.tsx index 310217b17..51ea9f94e 100644 --- a/webui/src/pages/admin-dashboard/tiers/tiers.tsx +++ b/webui/src/pages/admin-dashboard/tiers/tiers.tsx @@ -35,6 +35,7 @@ import { MainContext } from "../../../context"; import type { Tier } from "../../../extension-registry-types"; import { TierFormDialog } from "./tier-form-dialog"; import { DeleteTierDialog } from "./delete-tier-dialog"; +import {handleError} from "../../../utils"; export const Tiers: FC = () => { const abortController = useRef(new AbortController()); @@ -54,7 +55,7 @@ export const Tiers: FC = () => { const data = await service.admin.getTiers(abortController.current); setTiers(data.tiers); } catch (err: any) { - setError(err.message || "Failed to load tiers"); + setError(handleError(err)); } finally { setLoading(false); } @@ -81,28 +82,20 @@ export const Tiers: FC = () => { }; const handleFormSubmit = async (formData: Tier) => { - try { - if (selectedTier) { - // update existing tier - await service.admin.updateTier(abortController.current, selectedTier.name, formData); - } else { - // create new tier - await service.admin.createTier(abortController.current, formData); - } - await loadTiers(); - } catch (err: any) { - throw new Error(err.message || "Failed to save tier"); + if (selectedTier) { + // update existing tier + await service.admin.updateTier(abortController.current, selectedTier.name, formData); + } else { + // create new tier + await service.admin.createTier(abortController.current, formData); } + await loadTiers(); }; const handleDeleteConfirm = async () => { - try { - if (selectedTier) { - await service.admin.deleteTier(abortController.current, selectedTier.name); - await loadTiers(); - } - } catch (err: any) { - throw new Error(err.message || "Failed to delete tier"); + if (selectedTier) { + await service.admin.deleteTier(abortController.current, selectedTier.name); + await loadTiers(); } }; From e53434c4d46db2d548aaeb56b10a7ecd5b416c2e Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Mon, 26 Jan 2026 13:30:42 +0100 Subject: [PATCH 25/45] remove dependency on bucket4j starter, self-contained config --- .../eclipse/openvsx/cache/CacheConfig.java | 1 - .../openvsx/ratelimit/UsageDataService.java | 7 +- .../TieredRateLimitAutoConfiguration.java | 78 ++++++++++++ .../config/TieredRateLimitConfig.java | 47 ++++--- .../TieredRateLimitFilterProperties.java | 117 ++++++++++++++++++ .../config/TieredRateLimitProperties.java | 93 ++++++++++++++ .../filter/TieredRateLimitServletFilter.java | 44 +++---- .../TieredRateLimitServletFilterFactory.java | 18 +-- .../CollectUsageStatsJobRequestHandler.java | 2 +- 9 files changed, 350 insertions(+), 57 deletions(-) create mode 100644 server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitAutoConfiguration.java create mode 100644 server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitFilterProperties.java create mode 100644 server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitProperties.java diff --git a/server/src/main/java/org/eclipse/openvsx/cache/CacheConfig.java b/server/src/main/java/org/eclipse/openvsx/cache/CacheConfig.java index 9a8a40984..4f39a00f6 100644 --- a/server/src/main/java/org/eclipse/openvsx/cache/CacheConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/cache/CacheConfig.java @@ -25,7 +25,6 @@ import org.eclipse.openvsx.search.SearchResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/UsageDataService.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/UsageDataService.java index 020341dca..fac63fba5 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/UsageDataService.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/UsageDataService.java @@ -28,6 +28,7 @@ import java.util.Map; public class UsageDataService { + // TODO: run the job every 10m public static final String COLLECT_USAGE_STATS_SCHEDULE = "*/15 * * * * *"; private final static String USAGE_DATA_KEY = "usage.customer"; @@ -49,7 +50,7 @@ public void incrementUsage(Customer customer) { var key = customer.getId(); var window = getCurrentUsageWindow(); var old = jedisCluster.hincrBy(USAGE_DATA_KEY, key + ":" + window, 1); - logger.info("Usage count for {}: {}", customer.getName(), old + 1); + logger.info("tiered-rate-limit: usage count for {}: {}", customer.getName(), old + 1); } public void persistUsageStats() { @@ -65,7 +66,7 @@ public void persistUsageStats() { var key = result.getKey(); var value = result.getValue(); - logger.debug("usage stats: {} - {}", key, value); + logger.debug("tiered-rate-limit: usage stats: {} - {}", key, value); var component = key.split(":"); var customerId = Long.parseLong(component[0]); @@ -74,7 +75,7 @@ public void persistUsageStats() { if (window < currentWindow) { var customer = customerService.getCustomerById(customerId); if (customer.isEmpty()) { - logger.warn("failed to find customer with id {}", customerId); + logger.warn("tiered-rate-limit: failed to find customer with id {}", customerId); } else { UsageStats stats = new UsageStats(); diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitAutoConfiguration.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitAutoConfiguration.java new file mode 100644 index 000000000..6ad37d412 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitAutoConfiguration.java @@ -0,0 +1,78 @@ +/* + * 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.ratelimit.config; + +import org.eclipse.openvsx.ratelimit.filter.TieredRateLimitServletFilter; +import org.eclipse.openvsx.ratelimit.filter.TieredRateLimitServletFilterFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.web.server.WebServerFactoryCustomizer; +import org.springframework.boot.web.servlet.server.ConfigurableServletWebServerFactory; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.util.StringUtils; + +import java.util.concurrent.atomic.AtomicInteger; + +@Configuration +@ConditionalOnBean(TieredRateLimitConfig.class) +public class TieredRateLimitAutoConfiguration implements WebServerFactoryCustomizer { + + private final Logger logger = LoggerFactory.getLogger(TieredRateLimitAutoConfiguration.class); + + private final GenericApplicationContext context; + private final TieredRateLimitProperties properties; + private final TieredRateLimitServletFilterFactory filterFactory; + + public TieredRateLimitAutoConfiguration( + GenericApplicationContext context, + TieredRateLimitProperties properties, + TieredRateLimitServletFilterFactory filterFactory + ) { + this.context = context; + this.properties = properties; + this.filterFactory = filterFactory; + } + + @Override + public void customize(ConfigurableServletWebServerFactory factory) { + var filterCount = new AtomicInteger(0); + properties + .getFilters() + .forEach(filter -> { + setDefaults(filter); + filterCount.incrementAndGet(); + + var beanName = ("tieredRateLimitServletRequestFilter" + filterCount); + context.registerBean( + beanName, + TieredRateLimitServletFilter.class, + () -> filterFactory.create(filter)); + + logger.debug("tiered-rate-limit:create-servlet-filter;{};{}", filterCount, filter.getUrl()); + }); + } + + private void setDefaults(TieredRateLimitFilterProperties filterProperties) { + if (!StringUtils.hasLength(filterProperties.getHttpResponseBody())) { + filterProperties.setHttpResponseBody(properties.getDefaultHttpResponseBody()); + } + if (!StringUtils.hasLength(filterProperties.getHttpContentType())) { + filterProperties.setHttpContentType(properties.getDefaultHttpContentType()); + } + if (filterProperties.getHttpStatusCode() == null) { + filterProperties.setHttpStatusCode(properties.getDefaultHttpStatusCode()); + } + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java index e006eaf79..778f6cbed 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java @@ -12,7 +12,6 @@ */ package org.eclipse.openvsx.ratelimit.config; -import com.giffing.bucket4j.spring.boot.starter.filter.servlet.ServletRateLimiterFilterFactory; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.github.benmanes.caffeine.cache.Scheduler; @@ -20,37 +19,60 @@ import io.github.bucket4j.distributed.proxy.ClientSideConfig; import io.github.bucket4j.distributed.proxy.ProxyManager; import io.github.bucket4j.redis.jedis.cas.JedisBasedProxyManager; +import io.micrometer.common.util.StringUtils; import org.eclipse.openvsx.ratelimit.CustomerService; import org.eclipse.openvsx.ratelimit.TieredRateLimitService; import org.eclipse.openvsx.ratelimit.UsageDataService; -import org.eclipse.openvsx.ratelimit.IdentityService; -import org.eclipse.openvsx.ratelimit.filter.TieredRateLimitServletFilterFactory; import org.eclipse.openvsx.repositories.RepositoryService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.data.redis.RedisProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.cache.CacheManager; import org.springframework.cache.caffeine.CaffeineCacheManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import redis.clients.jedis.DefaultJedisClientConfig; +import redis.clients.jedis.HostAndPort; import redis.clients.jedis.JedisCluster; import java.time.Duration; +import java.util.stream.Collectors; @Configuration -@ConditionalOnProperty(value = "ovsx.tiered-rate-limit.enabled", havingValue = "true") -@ConditionalOnBean(JedisCluster.class) -public class TieredRateLimitConfig { +@ConditionalOnProperty(prefix = TieredRateLimitProperties.PROPERTY_PREFIX, name = "enabled", havingValue = "true") +@EnableConfigurationProperties({TieredRateLimitProperties.class}) +public class TieredRateLimitConfig { private final Logger logger = LoggerFactory.getLogger(TieredRateLimitConfig.class); public static final String CACHE_RATE_LIMIT_CUSTOMER = "ratelimit.customer"; public static final String CACHE_RATE_LIMIT_TOKEN = "ratelimit.token"; + @Bean + public JedisCluster jedisCluster(RedisProperties properties) { + logger.info("Configure jedis-cluster rate-limiting cache"); + var configBuilder = DefaultJedisClientConfig.builder(); + var username = properties.getUsername(); + if(StringUtils.isNotEmpty(username)) { + configBuilder.user(username); + } + var password = properties.getPassword(); + if(StringUtils.isNotEmpty(password)) { + configBuilder.password(password); + } + + var nodes = properties.getCluster().getNodes().stream() + .map(HostAndPort::from) + .collect(Collectors.toSet()); + + return new JedisCluster(nodes, configBuilder.build()); + } + @Bean UsageDataService usageDataService(RepositoryService repositories, CustomerService customerService, JedisCluster jedisCluster) { return new UsageDataService(repositories, customerService, jedisCluster); @@ -61,16 +83,6 @@ TieredRateLimitService tieredRateLimitService(ProxyManager proxyManager) return new TieredRateLimitService(proxyManager); } - @Bean - ServletRateLimiterFilterFactory tieredServletFilterFactory( - UsageDataService - customerUsageService, - IdentityService identityService, - TieredRateLimitService rateLimitService - ) { - return new TieredRateLimitServletFilterFactory(customerUsageService, identityService, rateLimitService); - } - @Bean public Cache customerCache( @Value("${ovsx.caching.customer.tti:PT1H}") Duration timeToIdle, @@ -121,5 +133,4 @@ public ProxyManager jedisBasedProxyManager(JedisCluster jedisCluster) { .withExpirationAfterWriteStrategy(ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(Duration.ofSeconds(10)))) .build(); } - } diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitFilterProperties.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitFilterProperties.java new file mode 100644 index 000000000..41a180cde --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitFilterProperties.java @@ -0,0 +1,117 @@ +/* + * 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.ratelimit.config; + +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +import jakarta.validation.constraints.*; + +import org.springframework.core.Ordered; +import org.springframework.http.HttpStatus; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +public class TieredRateLimitFilterProperties implements Serializable { + + /** + * The URL to which the filter should be registered + */ + @NotBlank + private String url = ".*"; + + @AssertTrue(message = "Invalid filter URL regex pattern.") + @JsonIgnore + public boolean isUrlValid() { + try { + Pattern.compile(url); + return !url.equals("/*"); + } catch (PatternSyntaxException e) { + return false; + } + } + + /** + * The filter order has a default of the highest precedence reduced by 10 + */ + @NotNull + private Integer filterOrder = Ordered.HIGHEST_PRECEDENCE + 10; + + /** + * The HTTP Content-Type which should be returned + */ + private String httpContentType; + + /** + * The HTTP status code which should be returned when limiting the rate. + */ + private HttpStatus httpStatusCode; + + /** + * The HTTP content which should be used in case of rate limiting + */ + private String httpResponseBody; + + private Map httpResponseHeaders = new HashMap<>(); + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public Integer getFilterOrder() { + return filterOrder; + } + + public void setFilterOrder(Integer filterOrder) { + this.filterOrder = filterOrder; + } + + public String getHttpContentType() { + return httpContentType; + } + + public void setHttpContentType(String httpContentType) { + this.httpContentType = httpContentType; + } + + public HttpStatus getHttpStatusCode() { + return httpStatusCode; + } + + public void setHttpStatusCode(HttpStatus httpStatusCode) { + this.httpStatusCode = httpStatusCode; + } + + public String getHttpResponseBody() { + return httpResponseBody; + } + + public void setHttpResponseBody(String httpResponseBody) { + this.httpResponseBody = httpResponseBody; + } + + public Map getHttpResponseHeaders() { + return httpResponseHeaders; + } + + public void setHttpResponseHeaders(Map httpResponseHeaders) { + this.httpResponseHeaders = httpResponseHeaders; + } +} \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitProperties.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitProperties.java new file mode 100644 index 000000000..35eca1cf3 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitProperties.java @@ -0,0 +1,93 @@ +/* + * 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.ratelimit.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.springframework.http.HttpStatus; + +import java.util.ArrayList; +import java.util.List; + +@ConfigurationProperties(prefix = TieredRateLimitProperties.PROPERTY_PREFIX) +public class TieredRateLimitProperties { + + public static final String PROPERTY_PREFIX = "ovsx.tiered-rate-limit"; + + /** + * Enables or disables the tiered rate limit mechanism. + */ + @NotNull + private Boolean enabled = false; + + @Valid + private List filters = new ArrayList<>(); + + @NotBlank + private String defaultHttpContentType = "application/json"; + + @NotNull + private HttpStatus defaultHttpStatusCode = HttpStatus.TOO_MANY_REQUESTS; + + /** + * The HTTP content which should be used in case of rate limiting + */ + private String defaultHttpResponseBody = "{ \"message\": \"Too many requests!\" }"; + + public boolean getEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public List getFilters() { + return filters; + } + + public void setFilters(List filters) { + this.filters = filters; + } + + public String getDefaultHttpContentType() { + return defaultHttpContentType; + } + + public void setDefaultHttpContentType(String defaultHttpContentType) { + this.defaultHttpContentType = defaultHttpContentType; + } + + public HttpStatus getDefaultHttpStatusCode() { + return defaultHttpStatusCode; + } + + public void setDefaultHttpStatusCode(HttpStatus defaultHttpStatusCode) { + this.defaultHttpStatusCode = defaultHttpStatusCode; + } + + public String getDefaultHttpResponseBody() { + return defaultHttpResponseBody; + } + + public void setDefaultHttpResponseBody(String defaultHttpResponseBody) { + this.defaultHttpResponseBody = defaultHttpResponseBody; + } + + public static String getPropertyPrefix() { + return PROPERTY_PREFIX; + } +} \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java index 3f02613e9..662fa2b93 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java @@ -12,9 +12,6 @@ */ package org.eclipse.openvsx.ratelimit.filter; -import com.giffing.bucket4j.spring.boot.starter.context.RateLimitResult; -import com.giffing.bucket4j.spring.boot.starter.context.properties.FilterConfiguration; -import com.giffing.bucket4j.spring.boot.starter.filter.servlet.ServletRateLimitFilter; import io.github.bucket4j.Bandwidth; import io.github.bucket4j.BucketConfiguration; import io.github.bucket4j.ConsumptionProbe; @@ -24,43 +21,40 @@ import org.eclipse.openvsx.ratelimit.TieredRateLimitService; import org.eclipse.openvsx.ratelimit.UsageDataService; import org.eclipse.openvsx.ratelimit.IdentityService; +import org.eclipse.openvsx.ratelimit.config.TieredRateLimitFilterProperties; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.core.Ordered; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.time.Duration; import java.util.concurrent.TimeUnit; -public class TieredRateLimitServletFilter extends OncePerRequestFilter implements ServletRateLimitFilter { +public class TieredRateLimitServletFilter extends OncePerRequestFilter implements Ordered { private final Logger logger = LoggerFactory.getLogger(TieredRateLimitServletFilter.class); - private FilterConfiguration filterConfig; + private final TieredRateLimitFilterProperties filterProperties; private final UsageDataService customerUsageService; private final IdentityService identityService; private final TieredRateLimitService rateLimitService; public TieredRateLimitServletFilter( - FilterConfiguration filterConfig, + TieredRateLimitFilterProperties filterProperties, UsageDataService customerUsageService, IdentityService identityService, TieredRateLimitService rateLimitService ) { - this.filterConfig = filterConfig; + this.filterProperties = filterProperties; this.customerUsageService = customerUsageService; this.identityService = identityService; this.rateLimitService = rateLimitService; } - @Override - public void setFilterConfig(FilterConfiguration filterConfig) { - this.filterConfig = filterConfig; - } - @Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { - return !request.getRequestURI().matches(filterConfig.getUrl()); + return !request.getRequestURI().matches(filterProperties.getUrl()); } @Override @@ -128,20 +122,20 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse // } } - private void handleHttpResponseOnRateLimiting(HttpServletResponse httpResponse, RateLimitResult rateLimitResult) throws IOException { - httpResponse.setStatus(filterConfig.getHttpStatusCode().value()); - if (Boolean.FALSE.equals(filterConfig.getHideHttpResponseHeaders())) { - httpResponse.setHeader("X-Rate-Limit-Retry-After-Seconds", "" + TimeUnit.NANOSECONDS.toSeconds(rateLimitResult.getNanosToWaitForRefill())); - filterConfig.getHttpResponseHeaders().forEach(httpResponse::setHeader); - } - if (filterConfig.getHttpResponseBody() != null) { - httpResponse.setContentType(filterConfig.getHttpContentType()); - httpResponse.getWriter().append(filterConfig.getHttpResponseBody()); - } - } +// private void handleHttpResponseOnRateLimiting(HttpServletResponse httpResponse, RateLimitResult rateLimitResult) throws IOException { +// httpResponse.setStatus(filterConfig.getHttpStatusCode().value()); +// if (Boolean.FALSE.equals(filterConfig.getHideHttpResponseHeaders())) { +// httpResponse.setHeader("X-Rate-Limit-Retry-After-Seconds", "" + TimeUnit.NANOSECONDS.toSeconds(rateLimitResult.getNanosToWaitForRefill())); +// filterConfig.getHttpResponseHeaders().forEach(httpResponse::setHeader); +// } +// if (filterConfig.getHttpResponseBody() != null) { +// httpResponse.setContentType(filterConfig.getHttpContentType()); +// httpResponse.getWriter().append(filterConfig.getHttpResponseBody()); +// } +// } @Override public int getOrder() { - return filterConfig.getOrder(); + return filterProperties.getFilterOrder(); } } diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java index 1fa75e2ee..fe67eb393 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java @@ -12,16 +12,17 @@ */ package org.eclipse.openvsx.ratelimit.filter; -import com.giffing.bucket4j.spring.boot.starter.context.properties.FilterConfiguration; -import com.giffing.bucket4j.spring.boot.starter.filter.servlet.ServletRateLimitFilter; -import com.giffing.bucket4j.spring.boot.starter.filter.servlet.ServletRateLimiterFilterFactory; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import org.eclipse.openvsx.ratelimit.TieredRateLimitService; import org.eclipse.openvsx.ratelimit.UsageDataService; import org.eclipse.openvsx.ratelimit.IdentityService; +import org.eclipse.openvsx.ratelimit.config.TieredRateLimitConfig; +import org.eclipse.openvsx.ratelimit.config.TieredRateLimitFilterProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.stereotype.Component; -public class TieredRateLimitServletFilterFactory implements ServletRateLimiterFilterFactory { +@Component +@ConditionalOnBean(TieredRateLimitConfig.class) +public class TieredRateLimitServletFilterFactory { private final UsageDataService usageService; private final IdentityService identityService; private final TieredRateLimitService rateLimitService; @@ -36,8 +37,7 @@ public TieredRateLimitServletFilterFactory( this.rateLimitService = rateLimitService; } - @Override - public ServletRateLimitFilter create(FilterConfiguration filterConfig) { - return new TieredRateLimitServletFilter(filterConfig, usageService, identityService, rateLimitService); + public TieredRateLimitServletFilter create(TieredRateLimitFilterProperties filterProperties) { + return new TieredRateLimitServletFilter(filterProperties, usageService, identityService, rateLimitService); } } diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/jobs/CollectUsageStatsJobRequestHandler.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/jobs/CollectUsageStatsJobRequestHandler.java index 285df3b76..d303ba40a 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/jobs/CollectUsageStatsJobRequestHandler.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/jobs/CollectUsageStatsJobRequestHandler.java @@ -35,7 +35,7 @@ public CollectUsageStatsJobRequestHandler(Optional usageDataSe @Job(name = "Collect usage stats") public void run(CollectUsageStatsJobRequest collectUsageStatsJobRequest) throws Exception { if (usageDataService != null) { - logger.info("starting collect usage stats job"); + logger.info("tiered-rate-limit: starting collect usage stats job"); usageDataService.persistUsageStats(); } } From 7362e32496b6f695bfab4afc5c6f406d53b6aff3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez=20Hidalgo?= <31970428+gnugomez@users.noreply.github.com> Date: Mon, 26 Jan 2026 13:31:54 +0100 Subject: [PATCH 26/45] feat: using datagrid for tier & customer views (#1564) --- webui/package.json | 2 + .../components/data-grid-filter-operators.tsx | 126 ++++++++++ .../pages/admin-dashboard/components/index.ts | 18 ++ .../customers/customer-form-dialog.tsx | 7 +- .../admin-dashboard/customers/customers.tsx | 220 +++++++++++------- .../src/pages/admin-dashboard/tiers/tiers.tsx | 150 +++++++----- webui/yarn.lock | 112 +++++++++ 7 files changed, 481 insertions(+), 154 deletions(-) create mode 100644 webui/src/pages/admin-dashboard/components/data-grid-filter-operators.tsx create mode 100644 webui/src/pages/admin-dashboard/components/index.ts diff --git a/webui/package.json b/webui/package.json index c1e58f159..a0549b743 100644 --- a/webui/package.json +++ b/webui/package.json @@ -41,6 +41,8 @@ "@mui/base": "^5.0.0-beta.9", "@mui/icons-material": "^5.13.7", "@mui/material": "^5.13.7", + "@mui/system": "^5.15.15", + "@mui/x-data-grid": "^7", "clipboard-copy": "^4.0.1", "clsx": "^1.2.1", "dompurify": "^3.0.4", 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 new file mode 100644 index 000000000..1b344d251 --- /dev/null +++ b/webui/src/pages/admin-dashboard/components/data-grid-filter-operators.tsx @@ -0,0 +1,126 @@ +/* + * 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 React, { FC } from 'react'; +import { Autocomplete, TextField } from '@mui/material'; +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: React.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. + * + * @param options - Array of possible values to select from + * @returns Array of GridFilterOperator for use in column definition + * + */ +export const createMultiSelectFilterOperators = (options: string[]): GridFilterOperator[] => [ + { + label: 'is any of', + value: 'isAnyOf', + getApplyFilterFn: (filterItem) => { + if (!filterItem.value || (filterItem.value as string[]).length === 0) { + return null; + } + const filterValues = filterItem.value as string[]; + + return (value) => filterValues.indexOf(value as string) !== -1; + }, + InputComponent: (props: GridFilterInputValueProps) => ( + + ), + }, + { + label: 'is none of', + value: 'isNoneOf', + getApplyFilterFn: (filterItem) => { + if (!filterItem.value || (filterItem.value as string[]).length === 0) { + return null; + } + const filterValues = filterItem.value as string[]; + + return (value) => filterValues.indexOf(value as string) === -1; + }, + InputComponent: (props: GridFilterInputValueProps) => ( + + ), + }, +]; + +/** + * Creates filter operators for array-type columns with multi-select capability. + * Includes "contains any of" and "contains none of" operators. + * + * @param options - Array of possible values to select from + * @returns Array of GridFilterOperator for use in column definition + * + */ +export const createArrayContainsFilterOperators = (options: string[]): GridFilterOperator[] => [ + { + label: 'contains any of', + value: 'containsAnyOf', + getApplyFilterFn: (filterItem) => { + if (!filterItem.value || (filterItem.value as string[]).length === 0) { + return null; + } + const filterValues = filterItem.value as string[]; + + return (value) => filterValues.some(fv => (value as string[]).indexOf(fv) !== -1); + }, + InputComponent: (props: GridFilterInputValueProps) => ( + + ), + }, + { + label: 'contains none of', + value: 'containsNoneOf', + getApplyFilterFn: (filterItem) => { + if (!filterItem.value || (filterItem.value as string[]).length === 0) { + return null; + } + const filterValues = filterItem.value as string[]; + + return (value) => !filterValues.some(fv => (value as string[]).indexOf(fv) !== -1); + }, + InputComponent: (props: GridFilterInputValueProps) => ( + + ), + }, +]; diff --git a/webui/src/pages/admin-dashboard/components/index.ts b/webui/src/pages/admin-dashboard/components/index.ts new file mode 100644 index 000000000..809511413 --- /dev/null +++ b/webui/src/pages/admin-dashboard/components/index.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 + */ + +export { + MultiSelectFilterInput, + createMultiSelectFilterOperators, + createArrayContainsFilterOperators +} from './data-grid-filter-operators'; 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 5634e9639..b737b2e20 100644 --- a/webui/src/pages/admin-dashboard/customers/customer-form-dialog.tsx +++ b/webui/src/pages/admin-dashboard/customers/customer-form-dialog.tsx @@ -42,6 +42,7 @@ 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 @@ -127,7 +128,7 @@ export const CustomerFormDialog: FC = ({ open, customer const handleBlur = (e: React.FocusEvent) => { const { name } = e.target; setTouched(prev => ({ ...prev, [name]: true })); - + // Validate the specific field on blur validateField(name); }; @@ -168,7 +169,7 @@ export const CustomerFormDialog: FC = ({ open, customer // Validate all entries const invalidEntries = value.filter(cidr => !isValidCIDR(cidr.trim())); - + if (invalidEntries.length > 0) { setTouched(prev => ({ ...prev, cidrBlocks: true })); setErrors(prev => ({ @@ -176,7 +177,7 @@ export const CustomerFormDialog: FC = ({ open, customer cidrBlocks: `Invalid CIDR block(s): ${invalidEntries.join(', ')}. Please enter valid IPv4 or IPv6 CIDR notation.` })); } - + // Always update the value so the user can see and correct invalid entries setFormData(prev => ({ ...prev, diff --git a/webui/src/pages/admin-dashboard/customers/customers.tsx b/webui/src/pages/admin-dashboard/customers/customers.tsx index 895461847..5a955c7cf 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 React, {FC, useState, useEffect, useRef, useCallback} from "react"; +import React, { FC, useState, useEffect, useRef, useCallback, useMemo } from "react"; import { Box, Button, @@ -29,6 +29,7 @@ import { Stack, Chip } 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"; @@ -37,6 +38,7 @@ import type {Customer} from "../../../extension-registry-types"; import {CustomerFormDialog} from "./customer-form-dialog"; import {DeleteCustomerDialog} from "./delete-customer-dialog"; import {handleError} from "../../../utils"; +import { createMultiSelectFilterOperators, createArrayContainsFilterOperators } from "../components"; export const Customers: FC = () => { const abortController = useRef(new AbortController()); @@ -110,21 +112,110 @@ export const Customers: FC = () => { setSelectedCustomer(undefined); }; - return ( - - - - Customer Management - - - + // Extract unique values for filter dropdowns + const tierOptions = useMemo(() => + [...new Set(customers.map(c => c.tier?.name).filter(Boolean))] as string[], + [customers] + ); + const stateOptions = useMemo(() => + [...new Set(customers.map(c => c.state).filter(Boolean))], + [customers] + ); + const cidrBlockOptions = useMemo(() => { + const allCidrs = customers.reduce((acc, c) => acc.concat(c.cidrBlocks), []); + return [...new Set(allCidrs)]; + }, [customers]); + + const columns: GridColDef[] = [ + { field: 'name', headerName: 'Name', flex: 1, minWidth: 150 }, + { + field: 'tier', + headerName: 'Tier', + flex: 1, + minWidth: 120, + valueGetter: (value: Customer['tier']) => value?.name || '', + filterOperators: createMultiSelectFilterOperators(tierOptions) + }, + { + field: 'state', + headerName: 'State', + flex: 1, + minWidth: 100, + filterOperators: createMultiSelectFilterOperators(stateOptions) + }, + { + field: 'cidrBlocks', + headerName: 'CIDR Blocks', + flex: 2, + minWidth: 200, + sortable: false, + filterOperators: createArrayContainsFilterOperators(cidrBlockOptions), + renderCell: (params: GridRenderCellParams) => { + const cidrBlocks = params.row.cidrBlocks; + const maxVisible = 2; + const visibleCidrs = cidrBlocks.slice(0, maxVisible); + const remainingCount = cidrBlocks.length - maxVisible; + + return ( + + {visibleCidrs.map((cidr: string) => ( + + ))} + {remainingCount > 0 && ( + + )} + + ); + } + }, + { + field: 'actions', + headerName: 'Actions', + width: 120, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => ( + <> + handleEditClick(params.row)} + title='Edit' + > + + + handleDeleteClick(params.row)} + title='Delete' + color='error' + > + + + + ) + } + ]; + + return ( + + + + Customer Management + + + {error && ( setError(null)}> @@ -138,80 +229,31 @@ export const Customers: FC = () => { )} - {!loading && customers.length === 0 && ( - - - No customers found. Create one to get started. - - - )} + { !loading && customers.length === 0 && ( + + + No customers found. Create one to get started. + + + )} - {!loading && customers.length > 0 && ( - - - - - Name - Tier - State - CIDR Blocks - - Actions - - - - - {customers.map(customer => ( - - {customer.name} - {customer.tier?.name || "-"} - - - - - {customer.cidrBlocks.length > 0 - ? customer.cidrBlocks.map((value) => ( - - )) - : "-" - } - - - - handleEditClick(customer)} - title='Edit customer' - color='primary' - > - - - handleDeleteClick(customer)} - title='Delete customer' - color='error' - > - - - - - - ))} - -
-
- )} + { !loading && customers.length > 0 && ( + + row.name} + pageSizeOptions={[20, 35, 50]} + initialState={{ + pagination: { paginationModel: { pageSize: 20 } }, + }} + disableRowSelectionOnClick + sx={{ + flex: 1, + }} + /> + + )} { const abortController = useRef(new AbortController()); @@ -109,8 +104,77 @@ export const Tiers: FC = () => { setSelectedTier(undefined); }; + // Extract unique values for filter dropdowns + const refillStrategyOptions = useMemo(() => + [...new Set(tiers.map(t => t.refillStrategy).filter(Boolean))], + [tiers] + ); + + const columns: GridColDef[] = [ + { + field: 'name', + headerName: 'Name', + flex: 1, + minWidth: 150 + }, + { + field: 'description', + headerName: 'Description', + flex: 2, + minWidth: 200, + valueGetter: (value: string) => value || '-' + }, + { + field: 'capacity', + headerName: 'Capacity', + type: 'number', + width: 120, + valueFormatter: (value: number) => value.toLocaleString() + }, + { + field: 'duration', + headerName: 'Duration (s)', + type: 'number', + width: 130, + valueFormatter: (value: number) => value.toLocaleString() + }, + { + field: 'refillStrategy', + headerName: 'Refill Strategy', + width: 150, + filterOperators: createMultiSelectFilterOperators(refillStrategyOptions) + }, + { + field: 'actions', + headerName: 'Actions', + width: 120, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => ( + <> + handleEditClick(params.row)} + title='Edit tier' + color='primary' + > + + + handleDeleteClick(params.row)} + title='Delete tier' + color='error' + > + + + + ), + }, + ]; + return ( - + Tier Management @@ -138,7 +202,7 @@ export const Tiers: FC = () => { } { !loading && tiers.length === 0 && - + No tiers found. Create one to get started. @@ -146,57 +210,19 @@ export const Tiers: FC = () => { } { !loading && tiers.length > 0 && - - - - - Name - Description - - Capacity - - - Duration (s) - - Refill Strategy - - Actions - - - - - {tiers.map(tier => ( - - {tier.name} - {tier.description || "-"} - {tier.capacity.toLocaleString()} - {tier.duration.toLocaleString()} - {tier.refillStrategy} - - - handleEditClick(tier)} - title='Edit tier' - color='primary' - > - - - handleDeleteClick(tier)} - title='Delete tier' - color='error' - > - - - - - - ))} - -
-
+ + row.name} + pageSizeOptions={[20, 35, 50]} + initialState={{ + pagination: { paginationModel: { pageSize: 20 } }, + }} + disableRowSelectionOnClick + sx={{ flex: 1 }} + /> + } Date: Mon, 26 Jan 2026 14:00:42 +0100 Subject: [PATCH 27/45] remove unused imports --- webui/src/pages/admin-dashboard/customers/customers.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/webui/src/pages/admin-dashboard/customers/customers.tsx b/webui/src/pages/admin-dashboard/customers/customers.tsx index 5a955c7cf..799b9b1ec 100644 --- a/webui/src/pages/admin-dashboard/customers/customers.tsx +++ b/webui/src/pages/admin-dashboard/customers/customers.tsx @@ -16,12 +16,6 @@ import { Box, Button, Paper, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, Typography, CircularProgress, Alert, From caaf866ab8ca7d98de9b8c117bb06eb0042e135e Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Mon, 26 Jan 2026 14:00:51 +0100 Subject: [PATCH 28/45] fix import --- webui/src/components/sanitized-markdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webui/src/components/sanitized-markdown.tsx b/webui/src/components/sanitized-markdown.tsx index 9f1796a2b..e3f5fc877 100644 --- a/webui/src/components/sanitized-markdown.tsx +++ b/webui/src/components/sanitized-markdown.tsx @@ -9,7 +9,7 @@ ********************************************************************************/ import React, { FunctionComponent, useEffect } from 'react'; -import * as MarkdownIt from 'markdown-it'; +import MarkdownIt from 'markdown-it'; import * as MarkdownItAnchor from 'markdown-it-anchor'; import DOMPurify from 'dompurify'; import { Theme, styled } from '@mui/material/styles'; From 8d6111cf1db1df879cbf62604d3c986044ff44c0 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Mon, 26 Jan 2026 16:33:23 +0100 Subject: [PATCH 29/45] support caching buckets --- .../openvsx/ratelimit/CustomerService.java | 1 + .../openvsx/ratelimit/IdentityService.java | 35 ++++++-- .../openvsx/ratelimit/RateLimitService.java | 73 ++++++++++++++++ .../openvsx/ratelimit/ResolvedIdentity.java | 16 ++-- .../ratelimit/TieredRateLimitService.java | 32 ------- .../openvsx/ratelimit/UsageDataService.java | 5 ++ .../config/TieredRateLimitConfig.java | 32 +++---- .../config/TieredRateLimitProperties.java | 4 - .../filter/TieredRateLimitServletFilter.java | 87 ++++++------------- .../TieredRateLimitServletFilterFactory.java | 6 +- 10 files changed, 157 insertions(+), 134 deletions(-) create mode 100644 server/src/main/java/org/eclipse/openvsx/ratelimit/RateLimitService.java delete mode 100644 server/src/main/java/org/eclipse/openvsx/ratelimit/TieredRateLimitService.java 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 9cab66078..978784ac8 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerService.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerService.java @@ -21,6 +21,7 @@ import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Component; +import javax.annotation.Nullable; import java.util.Optional; import static org.eclipse.openvsx.ratelimit.config.TieredRateLimitConfig.CACHE_RATE_LIMIT_CUSTOMER; diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/IdentityService.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/IdentityService.java index eba3d9c9c..ea42acfe3 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/IdentityService.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/IdentityService.java @@ -25,23 +25,42 @@ public IdentityService(CustomerService customerService) { } public ResolvedIdentity resolveIdentity(HttpServletRequest request) { - var forwardedFor = request.getHeader("X-Forwarded-For"); - var ipAddress = forwardedFor != null ? forwardedFor : request.getRemoteAddr(); + String ipAddress = getIPAddress(request); + + String cacheKey = null; var token = request.getParameter("token"); if (token != null) { + // TODO: check if its a valid access token + cacheKey = "token_" + token.hashCode(); + } + var customer = customerService.getCustomerByIpAddress(ipAddress); + if (customer.isPresent() && cacheKey != null) { + cacheKey = "customer" + customer.get().getId(); } - var session = request.getSession(false); - var sessionId = session != null ? session.getId() : null; + if (cacheKey == null) { + var session = request.getSession(false); + var sessionId = session != null ? session.getId() : null; + if (sessionId != null) { + // TODO: check if its an active session + cacheKey = "session_" + sessionId.hashCode(); + } + } - var customer = customerService.getCustomerByIpAddress(ipAddress); - if (customer.isPresent()) { - return ResolvedIdentity.ofCustomer(customer.get()); + if (cacheKey == null) { + cacheKey = "ip_" + ipAddress; } + return new ResolvedIdentity(cacheKey, customer.orElse(null)); + } - return ResolvedIdentity.anonymous(ipAddress); + private String getIPAddress(HttpServletRequest request) { + // TODO: make this configurable rather than hardcode, + // if the server is run without proxy, someone + // could fake the X-Forwarded-For header + var forwardedFor = request.getHeader("X-Forwarded-For"); + return forwardedFor != null ? forwardedFor : request.getRemoteAddr(); } } diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/RateLimitService.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/RateLimitService.java new file mode 100644 index 000000000..f7bb895c7 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/RateLimitService.java @@ -0,0 +1,73 @@ +/* + * 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.ratelimit; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import io.github.bucket4j.BucketConfiguration; +import io.github.bucket4j.TokensInheritanceStrategy; +import io.github.bucket4j.distributed.proxy.ProxyManager; +import org.eclipse.openvsx.entities.RefillStrategy; +import org.eclipse.openvsx.ratelimit.config.TieredRateLimitConfig; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +import static org.eclipse.openvsx.ratelimit.config.TieredRateLimitConfig.CACHE_RATE_LIMIT_BUCKET; + +@Component +@ConditionalOnBean(TieredRateLimitConfig.class) +public class RateLimitService { + private final ProxyManager proxyManager; + + public RateLimitService(ProxyManager proxyManager) { + this.proxyManager = proxyManager; + } + + @Cacheable(value = CACHE_RATE_LIMIT_BUCKET, cacheManager = "rateLimitCacheManager") + public Bucket getBucket(ResolvedIdentity identity) { + var bandwidth = getBandwidth(identity); + var bucketConfiguration = + BucketConfiguration.builder() + .addLimit(bandwidth) + .build(); + + var bucket = proxyManager.builder().build(identity.cacheKey().getBytes(StandardCharsets.UTF_8), () -> bucketConfiguration); + bucket.replaceConfiguration(bucketConfiguration, TokensInheritanceStrategy.RESET); + return bucket; + } + + private Bandwidth getBandwidth(ResolvedIdentity identity) { + if (identity.isCustomer()) { + var tier = identity.getCustomer().getTier(); + var fillStage = Bandwidth.builder().capacity(tier.getCapacity()); + + var buildStage = switch (tier.getRefillStrategy()) { + case RefillStrategy.GREEDY -> fillStage.refillGreedy(tier.getCapacity(), tier.getDuration()); + case RefillStrategy.INTERVAL -> fillStage.refillIntervally(tier.getCapacity(), tier.getDuration()); + }; + + return buildStage.build(); + } else { + // TODO: get data for free tier from db + return Bandwidth.builder().capacity(100).refillGreedy(100, Duration.ofMinutes(1)).build(); + } + } + + @CacheEvict(value = CACHE_RATE_LIMIT_BUCKET, cacheManager = "rateLimitCacheManager", allEntries = true) + public void evictBucketCache() {} +} diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/ResolvedIdentity.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/ResolvedIdentity.java index 79f23663d..26db1d9ab 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/ResolvedIdentity.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/ResolvedIdentity.java @@ -15,9 +15,13 @@ import jakarta.validation.constraints.NotNull; import org.eclipse.openvsx.entities.Customer; +import javax.annotation.Nonnull; import javax.annotation.Nullable; -public record ResolvedIdentity(@Nullable Customer customer, String cacheKey) { +public record ResolvedIdentity( + @Nonnull String cacheKey, + @Nullable Customer customer +) { public boolean isCustomer() { return customer != null; @@ -27,15 +31,7 @@ public boolean isCustomer() { if (isCustomer()) { return customer; } else { - throw new RuntimeException("no customer associated to identity"); + throw new RuntimeException("no customer associated with identity"); } } - - public static ResolvedIdentity anonymous(String cacheKey) { - return new ResolvedIdentity(null, cacheKey); - } - - public static ResolvedIdentity ofCustomer(Customer customer) { - return new ResolvedIdentity(customer, String.valueOf(customer.getId())); - } } diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/TieredRateLimitService.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/TieredRateLimitService.java deleted file mode 100644 index 972f87dcf..000000000 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/TieredRateLimitService.java +++ /dev/null @@ -1,32 +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 - */ -package org.eclipse.openvsx.ratelimit; - -import io.github.bucket4j.Bucket; -import io.github.bucket4j.BucketConfiguration; -import io.github.bucket4j.distributed.proxy.ProxyManager; - -import java.nio.charset.StandardCharsets; - -public class TieredRateLimitService { - private final ProxyManager proxyManager; - - public TieredRateLimitService(ProxyManager proxyManager) { - this.proxyManager = proxyManager; - } - - public Bucket getBucket(String key, BucketConfiguration bucketConfiguration) { - Bucket bucket = proxyManager.builder().build(key.getBytes(StandardCharsets.UTF_8), bucketConfiguration); - return bucket; - } -} diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/UsageDataService.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/UsageDataService.java index fac63fba5..56e8a1f6b 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/UsageDataService.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/UsageDataService.java @@ -14,9 +14,12 @@ import org.eclipse.openvsx.entities.Customer; import org.eclipse.openvsx.entities.UsageStats; +import org.eclipse.openvsx.ratelimit.config.TieredRateLimitConfig; import org.eclipse.openvsx.repositories.RepositoryService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.stereotype.Component; import redis.clients.jedis.JedisCluster; import redis.clients.jedis.params.ScanParams; import redis.clients.jedis.resps.ScanResult; @@ -27,6 +30,8 @@ import java.time.ZoneOffset; import java.util.Map; +@Component +@ConditionalOnBean(TieredRateLimitConfig.class) public class UsageDataService { // TODO: run the job every 10m public static final String COLLECT_USAGE_STATS_SCHEDULE = "*/15 * * * * *"; diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java index 778f6cbed..73ac1a894 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java @@ -20,10 +20,6 @@ import io.github.bucket4j.distributed.proxy.ProxyManager; import io.github.bucket4j.redis.jedis.cas.JedisBasedProxyManager; import io.micrometer.common.util.StringUtils; -import org.eclipse.openvsx.ratelimit.CustomerService; -import org.eclipse.openvsx.ratelimit.TieredRateLimitService; -import org.eclipse.openvsx.ratelimit.UsageDataService; -import org.eclipse.openvsx.repositories.RepositoryService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; @@ -52,6 +48,7 @@ public class TieredRateLimitConfig { public static final String CACHE_RATE_LIMIT_CUSTOMER = "ratelimit.customer"; public static final String CACHE_RATE_LIMIT_TOKEN = "ratelimit.token"; + public static final String CACHE_RATE_LIMIT_BUCKET = "ratelimit.bucket"; @Bean public JedisCluster jedisCluster(RedisProperties properties) { @@ -73,16 +70,6 @@ public JedisCluster jedisCluster(RedisProperties properties) { return new JedisCluster(nodes, configBuilder.build()); } - @Bean - UsageDataService usageDataService(RepositoryService repositories, CustomerService customerService, JedisCluster jedisCluster) { - return new UsageDataService(repositories, customerService, jedisCluster); - } - - @Bean - TieredRateLimitService tieredRateLimitService(ProxyManager proxyManager) { - return new TieredRateLimitService(proxyManager); - } - @Bean public Cache customerCache( @Value("${ovsx.caching.customer.tti:PT1H}") Duration timeToIdle, @@ -109,16 +96,31 @@ public Cache tokenCache( .build(); } + @Bean + public Cache bucketCache( + @Value("${ovsx.caching.bucket.tti:PT1H}") Duration timeToIdle, + @Value("${ovsx.caching.bucket.max-size:10000}") long maxSize + ) { + return Caffeine.newBuilder() + .expireAfterAccess(timeToIdle) + .maximumSize(maxSize) + .scheduler(Scheduler.systemScheduler()) + .recordStats() + .build(); + } + @Bean @Qualifier("rateLimitCacheManager") public CacheManager rateLimitCacheManager( Cache customerCache, - Cache tokenCache + Cache tokenCache, + Cache bucketCache ) { logger.info("Configure rate limit cache manager"); CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager(); caffeineCacheManager.registerCustomCache(CACHE_RATE_LIMIT_CUSTOMER, customerCache); caffeineCacheManager.registerCustomCache(CACHE_RATE_LIMIT_TOKEN, tokenCache); + caffeineCacheManager.registerCustomCache(CACHE_RATE_LIMIT_BUCKET, bucketCache); return caffeineCacheManager; } diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitProperties.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitProperties.java index 35eca1cf3..31c151b0e 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitProperties.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitProperties.java @@ -86,8 +86,4 @@ public String getDefaultHttpResponseBody() { public void setDefaultHttpResponseBody(String defaultHttpResponseBody) { this.defaultHttpResponseBody = defaultHttpResponseBody; } - - public static String getPropertyPrefix() { - return PROPERTY_PREFIX; - } } \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java index 662fa2b93..072c7bb86 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java @@ -12,13 +12,11 @@ */ package org.eclipse.openvsx.ratelimit.filter; -import io.github.bucket4j.Bandwidth; -import io.github.bucket4j.BucketConfiguration; import io.github.bucket4j.ConsumptionProbe; import jakarta.servlet.*; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.eclipse.openvsx.ratelimit.TieredRateLimitService; +import org.eclipse.openvsx.ratelimit.RateLimitService; import org.eclipse.openvsx.ratelimit.UsageDataService; import org.eclipse.openvsx.ratelimit.IdentityService; import org.eclipse.openvsx.ratelimit.config.TieredRateLimitFilterProperties; @@ -28,7 +26,6 @@ import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; -import java.time.Duration; import java.util.concurrent.TimeUnit; public class TieredRateLimitServletFilter extends OncePerRequestFilter implements Ordered { @@ -38,13 +35,13 @@ public class TieredRateLimitServletFilter extends OncePerRequestFilter implement private final TieredRateLimitFilterProperties filterProperties; private final UsageDataService customerUsageService; private final IdentityService identityService; - private final TieredRateLimitService rateLimitService; + private final RateLimitService rateLimitService; public TieredRateLimitServletFilter( TieredRateLimitFilterProperties filterProperties, UsageDataService customerUsageService, IdentityService identityService, - TieredRateLimitService rateLimitService + RateLimitService rateLimitService ) { this.filterProperties = filterProperties; this.customerUsageService = customerUsageService; @@ -53,7 +50,7 @@ public TieredRateLimitServletFilter( } @Override - protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + protected boolean shouldNotFilter(HttpServletRequest request) { return !request.getRequestURI().matches(filterProperties.getUrl()); } @@ -65,74 +62,40 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse if (identity.isCustomer()) { var customer = identity.getCustomer(); - logger.info("updating usage status for customer {}", customer.getName()); + logger.info("tiered-rate-limit: updating usage status for customer {}", customer.getName()); customerUsageService.incrementUsage(customer); } - var bucketConfiguration = BucketConfiguration.builder() - .addLimit(Bandwidth.simple(200L, Duration.ofMinutes(1L))) - .build(); + var bucket = rateLimitService.getBucket(identity); - var bucket = rateLimitService.getBucket(identity.cacheKey(), bucketConfiguration); + // TODO: return ratelimit from service for bucket + // response.setHeader("X-RateLimit-Limit", Long.toString(100L)); ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1); logger.info(">>>>>>>> remainingTokens: {}", probe.getRemainingTokens()); if (probe.isConsumed()) { + response.setHeader("X-Rate-Limit-Remaining", Long.toString(probe.getRemainingTokens())); chain.doFilter(request, response); } else { - HttpServletResponse httpResponse = (HttpServletResponse) response; - httpResponse.setContentType("text/plain"); - httpResponse.setHeader("X-Rate-Limit-Retry-After-Seconds", "" + TimeUnit.NANOSECONDS.toSeconds(probe.getNanosToWaitForRefill())); - httpResponse.setStatus(429); - httpResponse.getWriter().append("Too many requests"); + handleHttpResponseOnRateLimiting(response, probe); } - -// boolean allConsumed = true; -// Long remainingLimit = null; -// for (var rl : filterConfig.getRateLimitChecks()) { -// var wrapper = rl.rateLimit(new ExpressionParams<>(request), null); -// if (wrapper != null && wrapper.getRateLimitResult() != null) { -// var rateLimitResult = wrapper.getRateLimitResult(); -// if (rateLimitResult.isConsumed()) { -// remainingLimit = RateLimitService.getRemainingLimit(remainingLimit, rateLimitResult); -// } else { -// allConsumed = false; -// handleHttpResponseOnRateLimiting(response, rateLimitResult); -// break; -// } -// if (filterConfig.getStrategy().equals(RateLimitConditionMatchingStrategy.FIRST)) { -// break; -// } -// } -// } -// -// if (allConsumed) { -// if (remainingLimit != null && Boolean.FALSE.equals(filterConfig.getHideHttpResponseHeaders())) { -// logger.debug("add-x-rate-limit-remaining-header;limit:{}", remainingLimit); -// response.setHeader("X-Rate-Limit-Remaining", "" + remainingLimit); -// } -// chain.doFilter(request, response); -// filterConfig.getPostRateLimitChecks() -// .forEach(rlc -> { -// var result = rlc.rateLimit(request, response); -// if (result != null) { -// logger.debug("post-rate-limit;remaining-tokens:{}", result.getRateLimitResult().getRemainingTokens()); -// } -// }); -// } } -// private void handleHttpResponseOnRateLimiting(HttpServletResponse httpResponse, RateLimitResult rateLimitResult) throws IOException { -// httpResponse.setStatus(filterConfig.getHttpStatusCode().value()); -// if (Boolean.FALSE.equals(filterConfig.getHideHttpResponseHeaders())) { -// httpResponse.setHeader("X-Rate-Limit-Retry-After-Seconds", "" + TimeUnit.NANOSECONDS.toSeconds(rateLimitResult.getNanosToWaitForRefill())); -// filterConfig.getHttpResponseHeaders().forEach(httpResponse::setHeader); -// } -// if (filterConfig.getHttpResponseBody() != null) { -// httpResponse.setContentType(filterConfig.getHttpContentType()); -// httpResponse.getWriter().append(filterConfig.getHttpResponseBody()); -// } -// } + private void handleHttpResponseOnRateLimiting(HttpServletResponse response, ConsumptionProbe probe) throws IOException { + response.setStatus(filterProperties.getHttpStatusCode().value()); + + response.setHeader("X-RateLimit-Remaining", "0"); + response.setHeader("X-Rate-Limit-Reset", "" + TimeUnit.NANOSECONDS.toSeconds(probe.getNanosToWaitForReset())); + var refillInSeconds = TimeUnit.NANOSECONDS.toSeconds(probe.getNanosToWaitForRefill()); + response.setHeader("X-Rate-Limit-Retry-After-Seconds", Long.toString(refillInSeconds)); + response.setHeader("Retry-After", Long.toString(refillInSeconds)); + + filterProperties.getHttpResponseHeaders().forEach(response::setHeader); + if (filterProperties.getHttpResponseBody() != null) { + response.setContentType(filterProperties.getHttpContentType()); + response.getWriter().append(filterProperties.getHttpResponseBody()); + } + } @Override public int getOrder() { diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java index fe67eb393..fc0fda8d2 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java @@ -12,7 +12,7 @@ */ package org.eclipse.openvsx.ratelimit.filter; -import org.eclipse.openvsx.ratelimit.TieredRateLimitService; +import org.eclipse.openvsx.ratelimit.RateLimitService; import org.eclipse.openvsx.ratelimit.UsageDataService; import org.eclipse.openvsx.ratelimit.IdentityService; import org.eclipse.openvsx.ratelimit.config.TieredRateLimitConfig; @@ -25,12 +25,12 @@ public class TieredRateLimitServletFilterFactory { private final UsageDataService usageService; private final IdentityService identityService; - private final TieredRateLimitService rateLimitService; + private final RateLimitService rateLimitService; public TieredRateLimitServletFilterFactory( UsageDataService usageService, IdentityService identityService, - TieredRateLimitService rateLimitService + RateLimitService rateLimitService ) { this.usageService = usageService; this.identityService = identityService; From b638442e20dd7ed61f406e9ddcdfd82d80005cc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez=20Hidalgo?= <31970428+gnugomez@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:18:16 +0100 Subject: [PATCH 30/45] feat: adding usage stats view (#1566) --- webui/package.json | 3 + webui/src/extension-registry-service.ts | 47 ++- webui/src/extension-registry-types.ts | 14 + .../pages/admin-dashboard/admin-dashboard.tsx | 8 +- .../customers/customer-form-dialog.tsx | 2 +- .../admin-dashboard/customers/customers.tsx | 29 +- .../customers/delete-customer-dialog.tsx | 2 +- .../tiers/delete-tier-dialog.tsx | 2 +- .../tiers/tier-form-dialog.tsx | 2 +- .../src/pages/admin-dashboard/tiers/tiers.tsx | 2 +- .../usage-stats/usage-stats-chart.tsx | 134 ++++++++ .../usage-stats/usage-stats-search.tsx | 83 +++++ .../usage-stats/usage-stats-utils.ts | 56 ++++ .../usage-stats/usage-stats.tsx | 137 ++++++++ webui/yarn.lock | 295 +++++++++++++++++- 15 files changed, 797 insertions(+), 19 deletions(-) create mode 100644 webui/src/pages/admin-dashboard/usage-stats/usage-stats-chart.tsx create mode 100644 webui/src/pages/admin-dashboard/usage-stats/usage-stats-search.tsx create mode 100644 webui/src/pages/admin-dashboard/usage-stats/usage-stats-utils.ts create mode 100644 webui/src/pages/admin-dashboard/usage-stats/usage-stats.tsx diff --git a/webui/package.json b/webui/package.json index a0549b743..1075858f2 100644 --- a/webui/package.json +++ b/webui/package.json @@ -42,9 +42,12 @@ "@mui/icons-material": "^5.13.7", "@mui/material": "^5.13.7", "@mui/system": "^5.15.15", + "@mui/x-charts": "^6", "@mui/x-data-grid": "^7", + "@mui/x-date-pickers": "^6", "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", diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index 0a2cdbbf8..21ea3689e 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -12,7 +12,7 @@ import { Extension, UserData, ExtensionCategory, ExtensionReviewList, PersonalAccessToken, SearchResult, NewReview, SuccessResult, ErrorResult, CsrfTokenJson, isError, Namespace, NamespaceDetails, MembershipRole, SortBy, SortOrder, UrlString, NamespaceMembershipList, PublisherInfo, SearchEntry, RegistryVersion, - LoginProviders, Tier, TierList, Customer, CustomerList, + LoginProviders, Tier, TierList, Customer, CustomerList, UsageStatsList, } from './extension-registry-types'; import { createAbsoluteURL, addQuery } from './utils'; import { sendRequest, ErrorResponse } from './server-request'; @@ -494,6 +494,7 @@ export interface AdminService { createCustomer(abortController: AbortController, customer: Customer): Promise>; updateCustomer(abortController: AbortController, name: string, customer: Customer): Promise>; deleteCustomer(abortController: AbortController, name: string): Promise>; + getUsageStats(abortController: AbortController, customerName: string, startDate?: Date, endDate?: Date): Promise>; } export type AdminServiceConstructor = new (registry: ExtensionRegistryService) => AdminService; @@ -743,6 +744,50 @@ export class AdminServiceImpl implements AdminService { headers }, false); } + + /** + * Get usage stats for a customer within an optional date range. + * Currently returns mocked data. + * TODO: Replace with real backend call when endpoint is available. + */ + async getUsageStats( + abortController: AbortController, + customerName: string, + startDate?: Date, + endDate?: Date + ): Promise> { + // Simulate network delay + await new Promise(resolve => setTimeout(resolve, 300)); + + // Generate mocked usage stats data + const now = new Date(); + const usageStats = []; + + // Generate 90 days of mocked data (daily stats) + for (let i = 89; i >= 0; i--) { + const windowStart = new Date(now); + windowStart.setDate(windowStart.getDate() - i); + windowStart.setHours(0, 0, 0, 0); + + // Filter by date range if provided + if (startDate && windowStart < startDate) continue; + if (endDate) { + const endOfDay = new Date(endDate); + endOfDay.setHours(23, 59, 59, 999); + if (windowStart > endOfDay) continue; + } + + usageStats.push({ + id: 90 - i, + customerId: customerName.length, // Mock customer ID based on name + windowStart: windowStart.toISOString(), + duration: 86400, // 24 hours in seconds (daily stats) + count: Math.floor(Math.random() * 1000) + 100 // Random count between 100-1100 + }); + } + + return { usageStats }; + } } export interface ExtensionFilter { diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index 57ecd2b63..2be466c47 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -291,3 +291,17 @@ export interface Customer { export interface CustomerList { customers: Customer[]; } + +export interface UsageStats { + id: number; + customerId: number; + windowStart: string; // ISO timestamp + duration: number; // in seconds + count: number; +} + +export interface UsageStatsList { + usageStats: UsageStats[]; +} + +export type UsageStatsPeriod = 'daily' | 'weekly' | 'monthly'; diff --git a/webui/src/pages/admin-dashboard/admin-dashboard.tsx b/webui/src/pages/admin-dashboard/admin-dashboard.tsx index a91542283..2a1a9a4dd 100644 --- a/webui/src/pages/admin-dashboard/admin-dashboard.tsx +++ b/webui/src/pages/admin-dashboard/admin-dashboard.tsx @@ -25,9 +25,11 @@ import { PublisherAdmin } from './publisher-admin'; import PersonIcon from '@mui/icons-material/Person'; import PeopleIcon from '@mui/icons-material/People'; import StarIcon from '@mui/icons-material/Star'; +import BarChartIcon from '@mui/icons-material/BarChart'; import { Tiers } from './tiers/tiers'; import { Customers } from './customers/customers'; -import {LoginComponent} from "../../default/login"; +import { UsageStatsView } from './usage-stats/usage-stats'; +import { LoginComponent } from "../../default/login"; import AccountBoxIcon from "@mui/icons-material/AccountBox"; export namespace AdminDashboardRoutes { @@ -38,6 +40,7 @@ export namespace AdminDashboardRoutes { export const PUBLISHER_ADMIN = createRoute([ROOT, 'publisher']); export const TIERS = createRoute([ROOT, 'tiers']); export const CUSTOMERS = createRoute([ROOT, 'customers']); + export const USAGE_STATS = createRoute([ROOT, 'usage']); } const Message: FunctionComponent<{message: string}> = ({ message }) => { @@ -69,6 +72,7 @@ export const AdminDashboard: FunctionComponent = props => { } route={AdminDashboardRoutes.PUBLISHER_ADMIN} /> } route={AdminDashboardRoutes.TIERS} /> } route={AdminDashboardRoutes.CUSTOMERS} /> + } route={AdminDashboardRoutes.USAGE_STATS} /> @@ -81,6 +85,8 @@ export const AdminDashboard: FunctionComponent = props => { } /> } /> } /> + } /> + } /> } /> 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 b737b2e20..dd4669d69 100644 --- a/webui/src/pages/admin-dashboard/customers/customer-form-dialog.tsx +++ b/webui/src/pages/admin-dashboard/customers/customer-form-dialog.tsx @@ -33,7 +33,7 @@ import { 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 { handleError } from "../../../utils"; interface CustomerFormDialogProps { open: boolean; diff --git a/webui/src/pages/admin-dashboard/customers/customers.tsx b/webui/src/pages/admin-dashboard/customers/customers.tsx index 799b9b1ec..75bfdd4de 100644 --- a/webui/src/pages/admin-dashboard/customers/customers.tsx +++ b/webui/src/pages/admin-dashboard/customers/customers.tsx @@ -27,16 +27,19 @@ 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 {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 BarChartIcon from "@mui/icons-material/BarChart"; +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 { createMultiSelectFilterOperators, createArrayContainsFilterOperators } from "../components"; +import { AdminDashboardRoutes } from "../admin-dashboard"; +import { Link } from "react-router-dom"; export const Customers: FC = () => { const abortController = useRef(new AbortController()); - const {service} = React.useContext(MainContext); + const { service } = React.useContext(MainContext); const [customers, setCustomers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -170,11 +173,19 @@ export const Customers: FC = () => { { field: 'actions', headerName: 'Actions', - width: 120, + width: 160, sortable: false, filterable: false, renderCell: (params: GridRenderCellParams) => ( <> + + + handleEditClick(params.row)} @@ -212,13 +223,13 @@ export const Customers: FC = () => { {error && ( - setError(null)}> + setError(null)}> {error} )} {loading && ( - + )} diff --git a/webui/src/pages/admin-dashboard/customers/delete-customer-dialog.tsx b/webui/src/pages/admin-dashboard/customers/delete-customer-dialog.tsx index 6cfd70352..a1f552eac 100644 --- a/webui/src/pages/admin-dashboard/customers/delete-customer-dialog.tsx +++ b/webui/src/pages/admin-dashboard/customers/delete-customer-dialog.tsx @@ -25,7 +25,7 @@ import { } from '@mui/material'; import WarningIcon from '@mui/icons-material/Warning'; import type { Customer } from "../../../extension-registry-types"; -import {handleError} from "../../../utils"; +import { handleError } from "../../../utils"; interface DeleteCustomerDialogProps { open: boolean; diff --git a/webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx b/webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx index 7e7074dc9..d9f13b7ed 100644 --- a/webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx +++ b/webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx @@ -23,7 +23,7 @@ import { Alert } from '@mui/material'; import type { Tier } from '../../../extension-registry-types'; -import {handleError} from "../../../utils"; +import { handleError } from "../../../utils"; interface DeleteTierDialogProps { open: boolean; diff --git a/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx b/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx index 3b80e3c63..76d65306c 100644 --- a/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx +++ b/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx @@ -30,7 +30,7 @@ import { } from '@mui/material'; import type { SelectChangeEvent } from '@mui/material'; import { RefillStrategy, type Tier } from "../../../extension-registry-types"; -import {handleError} from "../../../utils"; +import { handleError } from "../../../utils"; type DurationUnit = 'seconds' | 'minutes' | 'hours'; diff --git a/webui/src/pages/admin-dashboard/tiers/tiers.tsx b/webui/src/pages/admin-dashboard/tiers/tiers.tsx index b77abbae1..2f5b8edcc 100644 --- a/webui/src/pages/admin-dashboard/tiers/tiers.tsx +++ b/webui/src/pages/admin-dashboard/tiers/tiers.tsx @@ -29,7 +29,7 @@ import { MainContext } from "../../../context"; import type { Tier } from "../../../extension-registry-types"; import { TierFormDialog } from "./tier-form-dialog"; import { DeleteTierDialog } from "./delete-tier-dialog"; -import {handleError} from "../../../utils"; +import { handleError } from "../../../utils"; import { createMultiSelectFilterOperators } from "../components"; export const Tiers: FC = () => { 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 new file mode 100644 index 000000000..2afe44675 --- /dev/null +++ b/webui/src/pages/admin-dashboard/usage-stats/usage-stats-chart.tsx @@ -0,0 +1,134 @@ +/* + * 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 React, { FC, useMemo } from "react"; +import { + Box, + Paper, + Typography, + Alert, + FormControl, + InputLabel, + Select, + MenuItem, + SelectChangeEvent, + useTheme, + Stack +} from "@mui/material"; +import { BarChart } 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 type { UsageStats, UsageStatsPeriod } from "../../../extension-registry-types"; +import { aggregateByPeriod } from "./usage-stats-utils"; + +interface UsageStatsChartProps { + usageStats: readonly UsageStats[]; + period: UsageStatsPeriod; + startDate: Date | null; + endDate: Date | null; + onPeriodChange: (event: SelectChangeEvent) => void; + onStartDateChange: (date: Date | null) => void; + onEndDateChange: (date: Date | null) => void; +} + +export const UsageStatsChart: FC = ({ + usageStats, + period, + startDate, + endDate, + onPeriodChange, + onStartDateChange, + onEndDateChange +}) => { + const theme = useTheme(); + + const aggregatedData = useMemo( + () => aggregateByPeriod([...usageStats], period), + [usageStats, period] + ); + + const totalRequests = useMemo( + () => aggregatedData.reduce((sum, d) => sum + d.count, 0), + [aggregatedData] + ); + + if (usageStats.length === 0) { + return No usage data available for this customer.; + } + + return ( + + + + Filters + + + + + + Aggregation + + + + + + + d.period), + }]} + series={[{ + data: aggregatedData.map(d => d.count), + label: 'Request Count', + color: theme.palette.primary.main + }]} + height={400} + slotProps={{ + legend: { position: { vertical: 'top', horizontal: 'right' } } + }} + /> + + + + + Total requests in selected range: {totalRequests.toLocaleString()} + {usageStats.length > 0 && <> ({usageStats.length} data points)} + + + + ); +}; \ No newline at end of file diff --git a/webui/src/pages/admin-dashboard/usage-stats/usage-stats-search.tsx b/webui/src/pages/admin-dashboard/usage-stats/usage-stats-search.tsx new file mode 100644 index 000000000..da329872a --- /dev/null +++ b/webui/src/pages/admin-dashboard/usage-stats/usage-stats-search.tsx @@ -0,0 +1,83 @@ +/* + * 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 React, { FC } from "react"; +import { Paper, Autocomplete, InputBase, IconButton } from "@mui/material"; +import SearchIcon from '@mui/icons-material/Search'; +import type { Customer } from "../../../extension-registry-types"; + +interface CustomerSearchProps { + customers: Customer[]; + selectedCustomer: Customer | null; + loading: boolean; + onCustomerChange: (_: unknown, value: Customer | null) => void; + pageSettings?: { themeType?: string }; +} + +export const CustomerSearch: FC = ({ + customers, + selectedCustomer, + loading, + onCustomerChange, + pageSettings +}) => { + const searchIconColor = pageSettings?.themeType === 'dark' ? '#111111' : '#ffffff'; + + return ( + option.name} + value={selectedCustomer} + onChange={onCustomerChange} + loading={loading} + renderInput={(params) => { + const { ref, color, size, ...inputProps } = params.inputProps; + return ( + + + + + + + ); + }} + /> + ); +}; \ No newline at end of file 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 new file mode 100644 index 000000000..d6cfb2cf5 --- /dev/null +++ b/webui/src/pages/admin-dashboard/usage-stats/usage-stats-utils.ts @@ -0,0 +1,56 @@ +/* + * 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 { format, startOfWeek, startOfMonth } from 'date-fns'; +import type { UsageStats, UsageStatsPeriod } from "../../../extension-registry-types"; + +/** + * Maps each {@link UsageStatsPeriod} to a function that derives an aggregation key + * string from a given {@link Date}. The resulting keys are used to group usage + * statistics into daily, weekly, or monthly buckets. + * + * Each extractor must be deterministic for a given input date and return a + * human-readable string that can be used as a grouping key. + */ +export const periodKeyExtractors: Record string> = { + daily: (date) => format(date, 'yyyy-MM-dd'), + weekly: (date) => `Week of ${format(startOfWeek(date, { weekStartsOn: 1 }), 'yyyy-MM-dd')}`, + monthly: (date) => format(startOfMonth(date), 'yyyy-MM') +}; + +/** + * Aggregates usage statistics by a specified period, summing counts for each period key. + * + * @param stats - An array of UsageStats objects to aggregate. + * @param period - The period type (e.g., daily, weekly) used to extract keys from dates. + * @returns An array of objects, each containing a 'period' string key and the aggregated 'count' number, sorted by period. + */ +export const aggregateByPeriod = (stats: UsageStats[], period: UsageStatsPeriod) => { + const getKey = periodKeyExtractors[period]; + const aggregated = new Map(); + + for (const stat of stats) { + const key = getKey(new Date(stat.windowStart)); + aggregated.set(key, (aggregated.get(key) || 0) + stat.count); + } + + return Array.from(aggregated.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([period, count]) => ({ period, count })); +}; + +export const getDefaultStartDate = () => { + const date = new Date(); + date.setDate(date.getDate() - 30); + return date; +}; \ No newline at end of file diff --git a/webui/src/pages/admin-dashboard/usage-stats/usage-stats.tsx b/webui/src/pages/admin-dashboard/usage-stats/usage-stats.tsx new file mode 100644 index 000000000..3b244f16b --- /dev/null +++ b/webui/src/pages/admin-dashboard/usage-stats/usage-stats.tsx @@ -0,0 +1,137 @@ +/* + * 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 React, { FC, useState, useEffect, useRef, useMemo, useCallback } from "react"; +import { Box, Alert, SelectChangeEvent } from "@mui/material"; +import { useParams, useNavigate } from "react-router-dom"; +import { MainContext } from "../../../context"; +import type { UsageStats, UsageStatsPeriod, 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"; + +export const UsageStatsView: FC = () => { + const { customer } = useParams<{ customer: string }>(); + const navigate = useNavigate(); + const abortController = useRef(new AbortController()); + const { service, pageSettings } = React.useContext(MainContext); + + const [customers, setCustomers] = useState([]); + const [customersLoading, setCustomersLoading] = useState(true); + const [usageStats, setUsageStats] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [period, setPeriod] = useState('daily'); + const [startDate, setStartDate] = useState(getDefaultStartDate); + const [endDate, setEndDate] = useState(new Date()); + + // Load customers for autocomplete + useEffect(() => { + const loadCustomers = async () => { + try { + setCustomersLoading(true); + const data = await service.admin.getCustomers(abortController.current); + setCustomers(data.customers); + } catch (err) { + setError(handleError(err as Error)); + } finally { + setCustomersLoading(false); + } + }; + loadCustomers(); + return () => abortController.current.abort(); + }, [service]); + + const selectedCustomer = useMemo( + () => customers.find(c => c.name === customer) || null, + [customers, customer] + ); + + const handleCustomerChange = (_: unknown, value: Customer | null) => { + if (value) { + navigate(`${AdminDashboardRoutes.USAGE_STATS}/${value.name}`); + } else { + navigate(AdminDashboardRoutes.USAGE_STATS); + } + }; + + 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 || undefined, + endDate || undefined + ); + setUsageStats(data.usageStats); + } catch (err) { + setError(handleError(err as Error)); + } finally { + setLoading(false); + } + }, [service, customer, startDate, endDate]); + + useEffect(() => { + if (customer) { + loadUsageStats(); + } + }, [loadUsageStats, customer]); + + const handlePeriodChange = (event: SelectChangeEvent) => { + setPeriod(event.target.value as UsageStatsPeriod); + }; + + if (error) { + return {error}; + } + + return ( + + + ]} + listContainer={!customer && Select a customer to view usage statistics.} + loading={loading || customersLoading} + /> + {customer && ( + )} + + ); +}; diff --git a/webui/yarn.lock b/webui/yarn.lock index a98f68812..27c9ce483 100644 --- a/webui/yarn.lock +++ b/webui/yarn.lock @@ -287,7 +287,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.28.4": +"@babel/runtime@npm:^7.21.0, @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 @@ -781,7 +781,7 @@ __metadata: languageName: node linkType: hard -"@mui/base@npm:^5.0.0-beta.9": +"@mui/base@npm:^5.0.0-beta.22, @mui/base@npm:^5.0.0-beta.9": version: 5.0.0-dev.20240529-082515-213b5e33ab resolution: "@mui/base@npm:5.0.0-dev.20240529-082515-213b5e33ab" dependencies: @@ -951,6 +951,38 @@ __metadata: languageName: node linkType: hard +"@mui/types@npm:~7.2.15": + version: 7.2.24 + resolution: "@mui/types@npm:7.2.24" + peerDependencies: + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/5ed4f90ec62c7df901e58b53011bf6b377b48e13b07de9eeb15c7a6f3f759310f0682b64685c7762f660fad6edf4c8e05595313c93810fc63c54270b899b4a75 + languageName: node + linkType: hard + +"@mui/utils@npm:^5.14.16": + version: 5.17.1 + resolution: "@mui/utils@npm:5.17.1" + dependencies: + "@babel/runtime": "npm:^7.23.9" + "@mui/types": "npm:~7.2.15" + "@types/prop-types": "npm:^15.7.12" + clsx: "npm:^2.1.1" + prop-types: "npm:^15.8.1" + react-is: "npm:^19.0.0" + peerDependencies: + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/26efae9a9f84a817b016a93ab3e3c3d08533947f62b19d4a5f8cd67ebf6932b1f68c4e4ae677dc0d3397ecd1bf1cc8cb47ab83a345bcaa9b4f45c401ec9d3926 + languageName: node + linkType: hard + "@mui/utils@npm:^5.15.14": version: 5.15.14 resolution: "@mui/utils@npm:5.15.14" @@ -1007,6 +1039,35 @@ __metadata: languageName: node linkType: hard +"@mui/x-charts@npm:^6": + version: 6.19.8 + resolution: "@mui/x-charts@npm:6.19.8" + dependencies: + "@babel/runtime": "npm:^7.23.2" + "@mui/base": "npm:^5.0.0-beta.22" + "@react-spring/rafz": "npm:^9.7.3" + "@react-spring/web": "npm:^9.7.3" + clsx: "npm:^2.0.0" + d3-color: "npm:^3.1.0" + d3-scale: "npm:^4.0.2" + d3-shape: "npm:^3.2.0" + prop-types: "npm:^15.8.1" + peerDependencies: + "@emotion/react": ^11.9.0 + "@emotion/styled": ^11.8.1 + "@mui/material": ^5.4.1 + "@mui/system": ^5.4.1 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + "@emotion/react": + optional: true + "@emotion/styled": + optional: true + checksum: 10/f9dc94b7efb78b62d58104567dec15327ee86d5528327de802816cedb7e51dcc281942d2984be9178f38e07688fa0839a0ae2c8e799d19d784063deb4783fa94 + languageName: node + linkType: hard + "@mui/x-data-grid@npm:^7": version: 7.29.12 resolution: "@mui/x-data-grid@npm:7.29.12" @@ -1034,6 +1095,54 @@ __metadata: languageName: node linkType: hard +"@mui/x-date-pickers@npm:^6": + version: 6.20.2 + resolution: "@mui/x-date-pickers@npm:6.20.2" + dependencies: + "@babel/runtime": "npm:^7.23.2" + "@mui/base": "npm:^5.0.0-beta.22" + "@mui/utils": "npm:^5.14.16" + "@types/react-transition-group": "npm:^4.4.8" + clsx: "npm:^2.0.0" + prop-types: "npm:^15.8.1" + react-transition-group: "npm:^4.4.5" + peerDependencies: + "@emotion/react": ^11.9.0 + "@emotion/styled": ^11.8.1 + "@mui/material": ^5.8.6 + "@mui/system": ^5.8.0 + date-fns: ^2.25.0 || ^3.2.0 + date-fns-jalali: ^2.13.0-0 + dayjs: ^1.10.7 + luxon: ^3.0.2 + moment: ^2.29.4 + moment-hijri: ^2.1.2 + moment-jalaali: ^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + "@emotion/react": + optional: true + "@emotion/styled": + optional: true + date-fns: + optional: true + date-fns-jalali: + optional: true + dayjs: + optional: true + luxon: + optional: true + moment: + optional: true + moment-hijri: + optional: true + moment-jalaali: + optional: true + checksum: 10/7309a2ca5d115ec59cc919de46e3c4e101240fd807cda7226bb5f47d66ac3b9276468e1719bd30fb8af36d68fedd9c6467712a3c34da56dc599ef6416921ef71 + languageName: node + linkType: hard + "@mui/x-internals@npm:7.29.0": version: 7.29.0 resolution: "@mui/x-internals@npm:7.29.0" @@ -1120,6 +1229,72 @@ __metadata: languageName: node linkType: hard +"@react-spring/animated@npm:~9.7.5": + version: 9.7.5 + resolution: "@react-spring/animated@npm:9.7.5" + dependencies: + "@react-spring/shared": "npm:~9.7.5" + "@react-spring/types": "npm:~9.7.5" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: 10/f4130b7ffae25621514ff2b3873acab65c21d6acf8eab798ef1fe5ee917c07f4c75aaa19788244dce7d9a0d6586a794f59634f2809e2f6399d9766dfbd454837 + languageName: node + linkType: hard + +"@react-spring/core@npm:~9.7.5": + version: 9.7.5 + resolution: "@react-spring/core@npm:9.7.5" + dependencies: + "@react-spring/animated": "npm:~9.7.5" + "@react-spring/shared": "npm:~9.7.5" + "@react-spring/types": "npm:~9.7.5" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: 10/b76578ffbd26f66cce7212ab3335eea488c05a533acea6bc09c5357f3d5f7a2550e4588124fc7445f5effcb91f8b2ddf049a556c9c8786556740a90780cbd73b + languageName: node + linkType: hard + +"@react-spring/rafz@npm:^9.7.3, @react-spring/rafz@npm:~9.7.5": + version: 9.7.5 + resolution: "@react-spring/rafz@npm:9.7.5" + checksum: 10/25b2dfc674603251bb4645758b4b35e7807a887fe7b58e7257edd32993abb9d7e36cd381f831d532f45278460227357112dd008ca3d07f9d8694aedd59d786b8 + languageName: node + linkType: hard + +"@react-spring/shared@npm:~9.7.5": + version: 9.7.5 + resolution: "@react-spring/shared@npm:9.7.5" + dependencies: + "@react-spring/rafz": "npm:~9.7.5" + "@react-spring/types": "npm:~9.7.5" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: 10/4e8d7927a1543745f36600396250999d2e8fdb57d73b2cb8b4d859f35ba202cf3bdcc1a64c72ca495fcc8025f739b428b1735ab5159d01fc45ea30b568be11d8 + languageName: node + linkType: hard + +"@react-spring/types@npm:~9.7.5": + version: 9.7.5 + resolution: "@react-spring/types@npm:9.7.5" + checksum: 10/5b0edc00f31dcd3a8c5027130c9992ba286dd275112800c63f706da2b871837ca96b52f6a1f0796f38190a947d7ae7bf4916ec9b440b04a0bc0e5c57f6fd70aa + languageName: node + linkType: hard + +"@react-spring/web@npm:^9.7.3": + version: 9.7.5 + resolution: "@react-spring/web@npm:9.7.5" + dependencies: + "@react-spring/animated": "npm:~9.7.5" + "@react-spring/core": "npm:~9.7.5" + "@react-spring/shared": "npm:~9.7.5" + "@react-spring/types": "npm:~9.7.5" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: 10/ecd6c410d0277649c6a6dc19156a06cc7beb92ac79eb798ee18d30ca9bdf92ccf63ad7794b384471059f03d3dc8c612b26ca6aec42769d01e2a43d07919fd02b + languageName: node + linkType: hard + "@remix-run/router@npm:1.16.1": version: 1.16.1 resolution: "@remix-run/router@npm:1.16.1" @@ -1458,6 +1633,15 @@ __metadata: languageName: node linkType: hard +"@types/react-transition-group@npm:^4.4.8": + version: 4.4.12 + resolution: "@types/react-transition-group@npm:4.4.12" + peerDependencies: + "@types/react": "*" + checksum: 10/ea14bc84f529a3887f9954b753843820ac8a3c49fcdfec7840657ecc6a8800aad98afdbe4b973eb96c7252286bde38476fcf64b1c09527354a9a9366e516d9a2 + languageName: node + linkType: hard + "@types/react@npm:^18.2.14": version: 18.3.3 resolution: "@types/react@npm:18.3.3" @@ -2457,7 +2641,7 @@ __metadata: languageName: node linkType: hard -"clsx@npm:^2.1.0, clsx@npm:^2.1.1": +"clsx@npm:^2.0.0, clsx@npm:^2.1.0, clsx@npm:^2.1.1": version: 2.1.1 resolution: "clsx@npm:2.1.1" checksum: 10/cdfb57fa6c7649bbff98d9028c2f0de2f91c86f551179541cf784b1cfdc1562dcb951955f46d54d930a3879931a980e32a46b598acaea274728dbe068deca919 @@ -2648,6 +2832,85 @@ __metadata: languageName: node linkType: hard +"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3": + version: 3.2.4 + resolution: "d3-array@npm:3.2.4" + dependencies: + internmap: "npm:1 - 2" + checksum: 10/5800c467f89634776a5977f6dae3f4e127d91be80f1d07e3e6e35303f9de93e6636d014b234838eea620f7469688d191b3f41207a30040aab750a63c97ec1d7c + languageName: node + linkType: hard + +"d3-color@npm:1 - 3, d3-color@npm:^3.1.0": + version: 3.1.0 + resolution: "d3-color@npm:3.1.0" + checksum: 10/536ba05bfd9f4fcd6fa289b5974f5c846b21d186875684637e22bf6855e6aba93e24a2eb3712985c6af3f502fbbfa03708edb72f58142f626241a8a17258e545 + languageName: node + linkType: hard + +"d3-format@npm:1 - 3": + version: 3.1.2 + resolution: "d3-format@npm:3.1.2" + checksum: 10/811d913c2c7624cb0d2a8f0ccd7964c50945b3de3c7f7aa14c309fba7266a3ec53cbee8c05f6ad61b2b65b93e157c55a0e07db59bc3180c39dac52be8e841ab1 + languageName: node + linkType: hard + +"d3-interpolate@npm:1.2.0 - 3": + version: 3.0.1 + resolution: "d3-interpolate@npm:3.0.1" + dependencies: + d3-color: "npm:1 - 3" + checksum: 10/988d66497ef5c190cf64f8c80cd66e1e9a58c4d1f8932d776a8e3ae59330291795d5a342f5a97602782ccbef21a5df73bc7faf1f0dc46a5145ba6243a82a0f0e + languageName: node + linkType: hard + +"d3-path@npm:^3.1.0": + version: 3.1.0 + resolution: "d3-path@npm:3.1.0" + checksum: 10/8e97a9ab4930a05b18adda64cf4929219bac913a5506cf8585631020253b39309549632a5cbeac778c0077994442ddaaee8316ee3f380e7baf7566321b84e76a + languageName: node + linkType: hard + +"d3-scale@npm:^4.0.2": + version: 4.0.2 + resolution: "d3-scale@npm:4.0.2" + dependencies: + d3-array: "npm:2.10.0 - 3" + d3-format: "npm:1 - 3" + d3-interpolate: "npm:1.2.0 - 3" + d3-time: "npm:2.1.1 - 3" + d3-time-format: "npm:2 - 4" + checksum: 10/e2dc4243586eae2a0fdf91de1df1a90d51dfacb295933f0ca7e9184c31203b01436bef69906ad40f1100173a5e6197ae753cb7b8a1a8fcfda43194ea9cad6493 + languageName: node + linkType: hard + +"d3-shape@npm:^3.2.0": + version: 3.2.0 + resolution: "d3-shape@npm:3.2.0" + dependencies: + d3-path: "npm:^3.1.0" + checksum: 10/2e861f4d4781ee8abd85d2b435f848d667479dcf01a4e0db3a06600a5bdeddedb240f88229ec7b3bf7fa300c2b3526faeaf7e75f9a24dbf4396d3cc5358ff39d + languageName: node + linkType: hard + +"d3-time-format@npm:2 - 4": + version: 4.1.0 + resolution: "d3-time-format@npm:4.1.0" + dependencies: + d3-time: "npm:1 - 3" + checksum: 10/ffc0959258fbb90e3890bfb31b43b764f51502b575e87d0af2c85b85ac379120d246914d07fca9f533d1bcedc27b2841d308a00fd64848c3e2cad9eff5c9a0aa + languageName: node + linkType: hard + +"d3-time@npm:1 - 3, d3-time@npm:2.1.1 - 3": + version: 3.1.0 + resolution: "d3-time@npm:3.1.0" + dependencies: + d3-array: "npm:2 - 3" + checksum: 10/c110bed295ce63e8180e45b82a9b0ba114d5f33ff315871878f209c1a6d821caa505739a2b07f38d1396637155b8e7372632dacc018e11fbe8ceef58f6af806d + languageName: node + linkType: hard + "data-view-buffer@npm:^1.0.1": version: 1.0.1 resolution: "data-view-buffer@npm:1.0.1" @@ -2681,6 +2944,15 @@ __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" @@ -4082,6 +4354,13 @@ __metadata: languageName: node linkType: hard +"internmap@npm:1 - 2": + version: 2.0.3 + resolution: "internmap@npm:2.0.3" + checksum: 10/873e0e7fcfe32f999aa0997a0b648b1244508e56e3ea6b8259b5245b50b5eeb3853fba221f96692bd6d1def501da76c32d64a5cb22a0b26cdd9b445664f805e0 + languageName: node + linkType: hard + "interpret@npm:^3.1.1": version: 3.1.1 resolution: "interpret@npm:3.1.1" @@ -5159,7 +5438,9 @@ __metadata: "@mui/icons-material": "npm:^5.13.7" "@mui/material": "npm:^5.13.7" "@mui/system": "npm:^5.15.15" + "@mui/x-charts": "npm:^6" "@mui/x-data-grid": "npm:^7" + "@mui/x-date-pickers": "npm:^6" "@playwright/test": "npm:^1.55.1" "@stylistic/eslint-plugin": "npm:^2.11.0" "@types/babel__core": "npm:^7" @@ -5184,6 +5465,7 @@ __metadata: clipboard-copy: "npm:^4.0.1" clsx: "npm:^1.2.1" css-loader: "npm:^6.8.1" + date-fns: "npm:^2.30.0" dompurify: "npm:^3.0.4" eslint: "npm:^9.15.0" eslint-plugin-react: "npm:^7.37.2" @@ -5708,6 +5990,13 @@ __metadata: languageName: node linkType: hard +"react-is@npm:^19.0.0": + version: 19.2.4 + resolution: "react-is@npm:19.2.4" + checksum: 10/3360fc50a38c23299c5003a709949f2439b2901e77962eea78d892f526f710d05a7777b600b302f853583d1861979b00d7a0a071c89c6562eac5740ac29b9665 + languageName: node + linkType: hard + "react-is@npm:^19.2.3": version: 19.2.3 resolution: "react-is@npm:19.2.3" From 55b0404806f8fcbec327f0c417390f49d51829fa Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Tue, 27 Jan 2026 16:41:24 +0100 Subject: [PATCH 31/45] be more specific about dependencies and add missing types --- webui/package.json | 8 +++++--- webui/yarn.lock | 46 ++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/webui/package.json b/webui/package.json index 1075858f2..a8688f241 100644 --- a/webui/package.json +++ b/webui/package.json @@ -42,9 +42,9 @@ "@mui/icons-material": "^5.13.7", "@mui/material": "^5.13.7", "@mui/system": "^5.15.15", - "@mui/x-charts": "^6", - "@mui/x-data-grid": "^7", - "@mui/x-date-pickers": "^6", + "@mui/x-charts": "^6.19", + "@mui/x-data-grid": "^7.29", + "@mui/x-date-pickers": "^6.20", "clipboard-copy": "^4.0.1", "clsx": "^1.2.1", "date-fns": "^2.30.0", @@ -76,6 +76,8 @@ "@stylistic/eslint-plugin": "^2.11.0", "@types/babel__core": "^7", "@types/chai": "^4.3.5", + "@types/d3-scale": "^4.0", + "@types/d3-shape": "^3.1", "@types/dompurify": "^3.0.2", "@types/express": "^4.17.21", "@types/lodash": "^4.14.195", diff --git a/webui/yarn.lock b/webui/yarn.lock index 27c9ce483..36bc0f410 100644 --- a/webui/yarn.lock +++ b/webui/yarn.lock @@ -1039,7 +1039,7 @@ __metadata: languageName: node linkType: hard -"@mui/x-charts@npm:^6": +"@mui/x-charts@npm:^6.19": version: 6.19.8 resolution: "@mui/x-charts@npm:6.19.8" dependencies: @@ -1068,7 +1068,7 @@ __metadata: languageName: node linkType: hard -"@mui/x-data-grid@npm:^7": +"@mui/x-data-grid@npm:^7.29": version: 7.29.12 resolution: "@mui/x-data-grid@npm:7.29.12" dependencies: @@ -1095,7 +1095,7 @@ __metadata: languageName: node linkType: hard -"@mui/x-date-pickers@npm:^6": +"@mui/x-date-pickers@npm:^6.20": version: 6.20.2 resolution: "@mui/x-date-pickers@npm:6.20.2" dependencies: @@ -1412,6 +1412,38 @@ __metadata: languageName: node linkType: hard +"@types/d3-path@npm:*": + version: 3.1.1 + resolution: "@types/d3-path@npm:3.1.1" + checksum: 10/0437994d45d852ecbe9c4484e5abe504cd48751796d23798b6d829503a15563fdd348d93ac44489ba9c656992d16157f695eb889d9ce1198963f8e1dbabb1266 + languageName: node + linkType: hard + +"@types/d3-scale@npm:^4.0": + version: 4.0.9 + resolution: "@types/d3-scale@npm:4.0.9" + dependencies: + "@types/d3-time": "npm:*" + checksum: 10/2cae90a5e39252ae51388f3909ffb7009178582990462838a4edd53dd7e2e08121b38f0d2e1ac0e28e41167e88dea5b99e064ca139ba917b900a8020cf85362f + languageName: node + linkType: hard + +"@types/d3-shape@npm:^3.1": + version: 3.1.8 + resolution: "@types/d3-shape@npm:3.1.8" + dependencies: + "@types/d3-path": "npm:*" + checksum: 10/ebc161d49101d84409829fea516ba7ec71ad51a1e97438ca0fafc1c30b56b3feae802d220375323632723a338dda7237c652e831e0b53527a6222ab0d1bb7809 + languageName: node + linkType: hard + +"@types/d3-time@npm:*": + version: 3.0.4 + resolution: "@types/d3-time@npm:3.0.4" + checksum: 10/b1eb4255066da56023ad243fd4ae5a20462d73bd087a0297c7d49ece42b2304a4a04297568c604a38541019885b2bc35a9e0fd704fad218e9bc9c5f07dc685ce + languageName: node + linkType: hard + "@types/dompurify@npm:^3.0.2": version: 3.0.5 resolution: "@types/dompurify@npm:3.0.5" @@ -5438,13 +5470,15 @@ __metadata: "@mui/icons-material": "npm:^5.13.7" "@mui/material": "npm:^5.13.7" "@mui/system": "npm:^5.15.15" - "@mui/x-charts": "npm:^6" - "@mui/x-data-grid": "npm:^7" - "@mui/x-date-pickers": "npm:^6" + "@mui/x-charts": "npm:^6.19" + "@mui/x-data-grid": "npm:^7.29" + "@mui/x-date-pickers": "npm:^6.20" "@playwright/test": "npm:^1.55.1" "@stylistic/eslint-plugin": "npm:^2.11.0" "@types/babel__core": "npm:^7" "@types/chai": "npm:^4.3.5" + "@types/d3-scale": "npm:^4.0" + "@types/d3-shape": "npm:^3.1" "@types/dompurify": "npm:^3.0.2" "@types/express": "npm:^4.17.21" "@types/lodash": "npm:^4.14.195" From df7def32d97e19929c4dce1e4ed964012f0e62fe Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Tue, 27 Jan 2026 16:41:35 +0100 Subject: [PATCH 32/45] fix trailing whitespace --- .../src/pages/admin-dashboard/usage-stats/usage-stats-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d6cfb2cf5..cc83ce4cd 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 @@ -30,7 +30,7 @@ export const periodKeyExtractors: Record strin /** * Aggregates usage statistics by a specified period, summing counts for each period key. - * + * * @param stats - An array of UsageStats objects to aggregate. * @param period - The period type (e.g., daily, weekly) used to extract keys from dates. * @returns An array of objects, each containing a 'period' string key and the aggregated 'count' number, sorted by period. From 6e6d5389e6ae32708c2a6c8305998e03c3add24d Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Wed, 28 Jan 2026 16:22:47 +0100 Subject: [PATCH 33/45] fix unit tests --- .../repositories/CustomerRepository.java | 11 ++++- .../repositories/RepositoryService.java | 43 +++++++++---------- .../openvsx/repositories/TierRepository.java | 10 +++-- .../repositories/UsageStatsRepository.java | 6 +-- .../RepositoryServiceSmokeTest.java | 26 ++++++++++- 5 files changed, 64 insertions(+), 32 deletions(-) diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java index 02fe0367e..423f2b3af 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java @@ -11,16 +11,23 @@ import org.eclipse.openvsx.entities.Customer; import org.eclipse.openvsx.entities.Tier; -import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.repository.Repository; import java.util.List; import java.util.Optional; -public interface CustomerRepository extends JpaRepository { +public interface CustomerRepository extends Repository { + List findAll(); + + Optional findById(long id); + Customer findByNameIgnoreCase(String name); List findByTier(Tier tier); int countCustomersByTier(Tier tier); + + Customer save(Customer customer); + + void delete(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 a29843881..6d9fc7485 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -62,8 +62,8 @@ public class RepositoryService { private final MigrationItemJooqRepository migrationItemJooqRepo; private final SignatureKeyPairRepository signatureKeyPairRepo; private final SignatureKeyPairJooqRepository signatureKeyPairJooqRepo; - private final CustomerRepository customerRepo; private final TierRepository tierRepo; + private final CustomerRepository customerRepo; private final UsageStatsRepository usageStatsRepository; public RepositoryService( @@ -90,8 +90,8 @@ public RepositoryService( MigrationItemJooqRepository migrationItemJooqRepo, SignatureKeyPairRepository signatureKeyPairRepo, SignatureKeyPairJooqRepository signatureKeyPairJooqRepo, - CustomerRepository customerRepo, TierRepository tierRepo, + CustomerRepository customerRepo, UsageStatsRepository usageStatsRepository ) { this.namespaceRepo = namespaceRepo; @@ -117,8 +117,8 @@ public RepositoryService( this.migrationItemJooqRepo = migrationItemJooqRepo; this.signatureKeyPairRepo = signatureKeyPairRepo; this.signatureKeyPairJooqRepo = signatureKeyPairJooqRepo; - this.customerRepo = customerRepo; this.tierRepo = tierRepo; + this.customerRepo = customerRepo; this.usageStatsRepository = usageStatsRepository; } @@ -675,6 +675,22 @@ public boolean isDeleteAllVersions(String namespaceName, String extensionName, L return extensionVersionJooqRepo.isDeleteAllVersions(namespaceName, extensionName, targetVersions, user); } + public List findAllTiers() { + return tierRepo.findAll(); + } + + public Tier findTier(String name) { + return tierRepo.findByNameIgnoreCase(name); + } + + public Tier upsertTier(Tier tier) { + return tierRepo.save(tier); + } + + public void deleteTier(Tier tier) { + tierRepo.delete(tier); + } + public List findAllCustomers() { return customerRepo.findAll(); } @@ -703,24 +719,7 @@ public void deleteCustomer(Customer customer) { customerRepo.delete(customer); } - public List findAllTiers() { - return tierRepo.findAll(); - } - - public Tier findTier(String name) { - return tierRepo.findByNameIgnoreCase(name); - } - - public Tier upsertTier(Tier tier) { - return tierRepo.save(tier); - } - - public void deleteTier(Tier tier) { - tierRepo.delete(tier); - } - - @Transactional - public void saveUsageStats(UsageStats usageStats) { - usageStatsRepository.save(usageStats); + public UsageStats saveUsageStats(UsageStats usageStats) { + return usageStatsRepository.save(usageStats); } } diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/TierRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/TierRepository.java index 621f35544..607c0b133 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/TierRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/TierRepository.java @@ -10,12 +10,16 @@ package org.eclipse.openvsx.repositories; import org.eclipse.openvsx.entities.Tier; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.repository.CrudRepository; import org.springframework.data.repository.Repository; import java.util.List; -public interface TierRepository extends JpaRepository { +public interface TierRepository extends Repository { + List findAll(); + Tier findByNameIgnoreCase(String name); + + Tier save(Tier tier); + + void delete(Tier tier); } diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/UsageStatsRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/UsageStatsRepository.java index dac14599b..6d93483da 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/UsageStatsRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/UsageStatsRepository.java @@ -13,8 +13,8 @@ package org.eclipse.openvsx.repositories; import org.eclipse.openvsx.entities.UsageStats; -import org.springframework.data.repository.CrudRepository; - -public interface UsageStatsRepository extends CrudRepository { +import org.springframework.data.repository.Repository; +public interface UsageStatsRepository extends Repository { + UsageStats save(UsageStats usageStats); } 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 83d284d8c..98efe2a56 100644 --- a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java +++ b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java @@ -13,14 +13,17 @@ import jakarta.transaction.Transactional; import org.eclipse.openvsx.entities.*; import org.eclipse.openvsx.json.QueryRequest; +import org.eclipse.openvsx.storage.*; import org.eclipse.openvsx.util.ExtensionId; import org.junit.jupiter.api.Test; +import org.junit.runner.RunWith; import org.mockito.Mockito; import org.mockito.invocation.Invocation; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.domain.PageRequest; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit4.SpringRunner; import java.lang.reflect.Modifier; import java.time.LocalDateTime; @@ -38,6 +41,7 @@ */ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ActiveProfiles("test") +@RunWith(SpringRunner.class) class RepositoryServiceSmokeTest { private static final List STRING_LIST = List.of("id1", "id2"); @@ -68,7 +72,13 @@ void testExecuteQueries() { var keyPair = new SignatureKeyPair(); keyPair.setPrivateKey(new byte[0]); keyPair.setPublicKeyText(""); - Stream.of(extension, namespace, userData, extVersion, personalAccessToken, keyPair).forEach(em::persist); + var tier = new Tier(); + tier.setName("tier"); + var customer = new Customer(); + customer.setName("customer"); + var usageStats = new UsageStats(); + usageStats.setCustomer(customer); + Stream.of(extension, namespace, userData, extVersion, personalAccessToken, keyPair, tier, customer).forEach(em::persist); em.flush(); var page = PageRequest.ofSize(1); @@ -221,7 +231,19 @@ void testExecuteQueries() { () -> repositories.findVersion(userData,"version", "targetPlatform", "extensionName", "namespace"), () -> repositories.findLatestVersion(userData, "namespaceName", "extensionName"), () -> repositories.isDeleteAllVersions("namespaceName", "extensionName", Collections.emptyList(), userData), - () -> repositories.deactivateAccessTokens(userData) + () -> repositories.deactivateAccessTokens(userData), + () -> repositories.findAllCustomers(), + () -> repositories.findCustomersByTier(tier), + () -> repositories.countCustomersByTier(tier), + () -> repositories.findCustomerById(1L), + () -> repositories.findCustomer("customer"), + () -> repositories.upsertCustomer(customer), + () -> repositories.deleteCustomer(customer), + () -> repositories.findAllTiers(), + () -> repositories.findTier("tier"), + () -> repositories.upsertTier(tier), + () -> repositories.deleteTier(tier), + () -> repositories.saveUsageStats(usageStats) ); // check that we did not miss anything From 960d9ebe6db174a35387b55721f6c4ce1546a197 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Wed, 28 Jan 2026 16:23:15 +0100 Subject: [PATCH 34/45] allow subscription for redis --- redis.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redis.conf b/redis.conf index 8a0381af1..baf1094f2 100644 --- a/redis.conf +++ b/redis.conf @@ -7,6 +7,6 @@ appendonly yes maxmemory 64mb maxmemory-policy allkeys-lru user default off -user openvsx on >openvsx ~* +@all +user openvsx on >openvsx ~* +@all allchannels masteruser openvsx masterauth openvsx \ No newline at end of file From 514f1dfcc55b1365ebd0381df1280ac29a956593 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Wed, 28 Jan 2026 16:23:53 +0100 Subject: [PATCH 35/45] support subscription to redis update channel --- .../eclipse/openvsx/admin/RateLimitAPI.java | 12 ++++++- .../openvsx/ratelimit/RateLimitService.java | 36 +++++++++++++++++-- .../TieredRateLimitAutoConfiguration.java | 5 +++ .../config/TieredRateLimitConfig.java | 20 +++++++++++ 4 files changed, 69 insertions(+), 4 deletions(-) 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 fcf1d5f4c..be2a0975b 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java @@ -15,6 +15,7 @@ import org.eclipse.openvsx.entities.Customer; import org.eclipse.openvsx.entities.Tier; import org.eclipse.openvsx.json.*; +import org.eclipse.openvsx.ratelimit.RateLimitService; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.util.ErrorResultException; import org.slf4j.Logger; @@ -24,6 +25,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.Optional; + @RestController @RequestMapping("/admin/ratelimit") @@ -32,13 +35,16 @@ public class RateLimitAPI { private final RepositoryService repositories; private final AdminService admins; + private RateLimitService rateLimitService; public RateLimitAPI( RepositoryService repositories, - AdminService admins + AdminService admins, + Optional rateLimitService ) { this.repositories = repositories; this.admins = admins; + rateLimitService.ifPresent(service -> this.rateLimitService = service); } @GetMapping( @@ -104,6 +110,10 @@ public ResponseEntity updateTier(@PathVariable String name, @RequestBo var result = ResultJson.success("Updated tier '" + savedTier.getName() + "'"); admins.logAdminAction(adminUser, result); + if (rateLimitService != null) { + rateLimitService.evictConfigurationCache(); + } + return ResponseEntity.ok(savedTier.toJson()); } catch (Exception exc) { logger.error("failed updating tier {}", name, exc); diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/RateLimitService.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/RateLimitService.java index f7bb895c7..52d249802 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/RateLimitService.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/RateLimitService.java @@ -12,16 +12,23 @@ */ package org.eclipse.openvsx.ratelimit; +import com.giffing.bucket4j.spring.boot.starter.config.cache.CacheUpdateEvent; import io.github.bucket4j.Bandwidth; import io.github.bucket4j.Bucket; import io.github.bucket4j.BucketConfiguration; import io.github.bucket4j.TokensInheritanceStrategy; import io.github.bucket4j.distributed.proxy.ProxyManager; +import org.eclipse.openvsx.cache.JedisClusterCacheManager; import org.eclipse.openvsx.entities.RefillStrategy; import org.eclipse.openvsx.ratelimit.config.TieredRateLimitConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import java.nio.charset.StandardCharsets; @@ -32,10 +39,19 @@ @Component @ConditionalOnBean(TieredRateLimitConfig.class) public class RateLimitService { + private final Logger logger = LoggerFactory.getLogger(RateLimitService.class); + private final ProxyManager proxyManager; + private final JedisClusterCacheManager cacheManager; + private final CacheManager rateLimitCacheManager; - public RateLimitService(ProxyManager proxyManager) { + public RateLimitService( + ProxyManager proxyManager, + JedisClusterCacheManager cacheManager, + @Qualifier("rateLimitCacheManager") CacheManager rateLimitCacheManager) { this.proxyManager = proxyManager; + this.cacheManager = cacheManager; + this.rateLimitCacheManager = rateLimitCacheManager; } @Cacheable(value = CACHE_RATE_LIMIT_BUCKET, cacheManager = "rateLimitCacheManager") @@ -54,6 +70,9 @@ public Bucket getBucket(ResolvedIdentity identity) { private Bandwidth getBandwidth(ResolvedIdentity identity) { if (identity.isCustomer()) { var tier = identity.getCustomer().getTier(); + + logger.info("getting bandwidth for customer {} - {}", identity.getCustomer().getName(), tier.getCapacity()); + var fillStage = Bandwidth.builder().capacity(tier.getCapacity()); var buildStage = switch (tier.getRefillStrategy()) { @@ -64,10 +83,21 @@ private Bandwidth getBandwidth(ResolvedIdentity identity) { return buildStage.build(); } else { // TODO: get data for free tier from db - return Bandwidth.builder().capacity(100).refillGreedy(100, Duration.ofMinutes(1)).build(); + return Bandwidth.builder().capacity(10000).refillGreedy(10000, Duration.ofMinutes(5)).build(); } } @CacheEvict(value = CACHE_RATE_LIMIT_BUCKET, cacheManager = "rateLimitCacheManager", allEntries = true) - public void evictBucketCache() {} + public void evictBucketCache() { + logger.info("evict bucket cache"); + } + + public void evictConfigurationCache() { + cacheManager.setValue("test", "1"); + } + + @EventListener(CacheUpdateEvent.class) + public void onCacheUpdateEvent(CacheUpdateEvent event) { + evictBucketCache(); + } } diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitAutoConfiguration.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitAutoConfiguration.java index 6ad37d412..910d2038c 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitAutoConfiguration.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitAutoConfiguration.java @@ -12,6 +12,10 @@ */ package org.eclipse.openvsx.ratelimit.config; +import com.giffing.bucket4j.spring.boot.starter.config.cache.CacheUpdateEvent; +import com.giffing.bucket4j.spring.boot.starter.context.FilterMethod; +import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JConfiguration; +import com.giffing.bucket4j.spring.boot.starter.filter.servlet.ServletRateLimitFilter; import org.eclipse.openvsx.ratelimit.filter.TieredRateLimitServletFilter; import org.eclipse.openvsx.ratelimit.filter.TieredRateLimitServletFilterFactory; import org.slf4j.Logger; @@ -20,6 +24,7 @@ import org.springframework.boot.web.server.WebServerFactoryCustomizer; import org.springframework.boot.web.servlet.server.ConfigurableServletWebServerFactory; import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.EventListener; import org.springframework.context.support.GenericApplicationContext; import org.springframework.util.StringUtils; diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java index 73ac1a894..e53a4b463 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java @@ -12,6 +12,8 @@ */ package org.eclipse.openvsx.ratelimit.config; +import com.giffing.bucket4j.spring.boot.starter.config.condition.ConditionalOnFilterConfigCacheEnabled; +import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JConfiguration; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.github.benmanes.caffeine.cache.Scheduler; @@ -20,6 +22,8 @@ import io.github.bucket4j.distributed.proxy.ProxyManager; import io.github.bucket4j.redis.jedis.cas.JedisBasedProxyManager; import io.micrometer.common.util.StringUtils; +import org.eclipse.openvsx.cache.JedisClusterCacheListener; +import org.eclipse.openvsx.cache.JedisClusterCacheManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; @@ -30,6 +34,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.cache.CacheManager; import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import redis.clients.jedis.DefaultJedisClientConfig; @@ -46,6 +51,8 @@ public class TieredRateLimitConfig { private final Logger logger = LoggerFactory.getLogger(TieredRateLimitConfig.class); + public static final String CONFIG_CACHE = "ratelimit.config"; + public static final String CACHE_RATE_LIMIT_CUSTOMER = "ratelimit.customer"; public static final String CACHE_RATE_LIMIT_TOKEN = "ratelimit.token"; public static final String CACHE_RATE_LIMIT_BUCKET = "ratelimit.bucket"; @@ -70,6 +77,19 @@ public JedisCluster jedisCluster(RedisProperties properties) { return new JedisCluster(nodes, configBuilder.build()); } + @Bean + public JedisClusterCacheManager configCacheManager(JedisCluster jedisCluster) { + return new JedisClusterCacheManager<>(jedisCluster, CONFIG_CACHE, String.class); + } + + @Bean + public JedisClusterCacheListener configCacheListener( + JedisCluster jedisCluster, + ApplicationEventPublisher eventPublisher + ) { + return new JedisClusterCacheListener<>(jedisCluster, CONFIG_CACHE, String.class, String.class, eventPublisher); + } + @Bean public Cache customerCache( @Value("${ovsx.caching.customer.tti:PT1H}") Duration timeToIdle, From 07c06cf7436e2db0861e28d7debc7ea08639e698 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Mon, 2 Feb 2026 10:39:17 +0100 Subject: [PATCH 36/45] add tiertype, improve error handling --- .../db/migration/V1_58_1__RateLimit.sql | 4 +- .../eclipse/openvsx/admin/RateLimitAPI.java | 37 ++++++-- .../eclipse/openvsx/entities/Customer.java | 4 +- .../openvsx/entities/EnforcementState.java | 4 +- .../openvsx/entities/RefillStrategy.java | 4 +- .../org/eclipse/openvsx/entities/Tier.java | 63 +++++++++---- .../eclipse/openvsx/entities/TierType.java | 19 ++++ .../eclipse/openvsx/entities/UsageStats.java | 4 +- .../org/eclipse/openvsx/json/TierJson.java | 82 ++++++++++++++-- .../repositories/RepositoryService.java | 10 +- .../openvsx/repositories/TierRepository.java | 7 +- .../db/migration/V1_58__Rate_Limit.sql | 3 + webui/src/extension-registry-types.ts | 9 +- .../pages/admin-dashboard/admin-dashboard.tsx | 2 +- .../tiers/delete-tier-dialog.tsx | 4 +- .../tiers/tier-form-dialog.tsx | 94 ++++++++++++------- .../src/pages/admin-dashboard/tiers/tiers.tsx | 15 ++- 17 files changed, 281 insertions(+), 84 deletions(-) create mode 100644 server/src/main/java/org/eclipse/openvsx/entities/TierType.java diff --git a/server/src/dev/resources/db/migration/V1_58_1__RateLimit.sql b/server/src/dev/resources/db/migration/V1_58_1__RateLimit.sql index cdb66e952..e134b483f 100644 --- a/server/src/dev/resources/db/migration/V1_58_1__RateLimit.sql +++ b/server/src/dev/resources/db/migration/V1_58_1__RateLimit.sql @@ -1,4 +1,6 @@ -- add basic tier and assign customer for loopback IP to it -INSERT INTO tier (id, name, description, capacity, duration, refill_strategy) VALUES (1, 'basic', '', 100, 60, 'GREEDY'); +INSERT INTO tier (id, name, description, tier_type, capacity, duration, refill_strategy) VALUES (1, 'free', '', 'FREE', 50, 60, 'GREEDY'); +INSERT INTO tier (id, name, description, tier_type, capacity, duration, refill_strategy) VALUES (2, 'safety', '', 'SAFETY', 200, 60, 'GREEDY'); +INSERT INTO tier (id, name, description, tier_type, capacity, duration, refill_strategy) VALUES (3, 'basic', '', 'NON_FREE', 100, 60, 'GREEDY'); INSERT INTO customer (id, name, tier_id, state, cidr_blocks) VALUES (1, 'loopback', 1, 'EVALUATION', '127.0.0.1/32'); 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 be2a0975b..9d0eaa4a9 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.Customer; import org.eclipse.openvsx.entities.Tier; +import org.eclipse.openvsx.entities.TierType; import org.eclipse.openvsx.json.*; import org.eclipse.openvsx.ratelimit.RateLimitService; import org.eclipse.openvsx.repositories.RepositoryService; @@ -21,9 +22,12 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.dao.DataAccessException; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.server.ResponseStatusException; import java.util.Optional; @@ -73,19 +77,29 @@ public ResponseEntity createTier(@RequestBody TierJson tier) { try { var adminUser = admins.checkAdminUser(); - var existingTier = repositories.findTier(tier.name()); + var existingTier = repositories.findTier(tier.getName()); if (existingTier != null) { - return ResponseEntity.badRequest().build(); + return ResponseEntity.badRequest().body(TierJson.error("Tier with name " + tier.getName() + " already exists")); + } + + var tierType = TierType.valueOf(tier.getTierType()); + if (tierType != TierType.NON_FREE) { + var existingTiers = repositories.findTiersByTierType(TierType.valueOf(tier.getTierType())); + if (!existingTiers.isEmpty()) { + return ResponseEntity.badRequest().body(TierJson.error("Tier with type '" + tier.getTierType() + "' already exists")); + } } var savedTier = repositories.upsertTier(Tier.fromJson(tier)); - var result = ResultJson.success("Created tier '" + savedTier.getName() + "'"); + var result = savedTier.toJson(); + result.setSuccess("Created tier '" + savedTier.getName() + "'"); + admins.logAdminAction(adminUser, result); - return ResponseEntity.ok(savedTier.toJson()); + return ResponseEntity.ok(result); } catch (Exception exc) { - logger.error("failed creating tier {}", tier.name(), exc); + logger.error("failed creating tier {}", tier.getName(), exc); return ResponseEntity.internalServerError().build(); } } @@ -104,17 +118,26 @@ public ResponseEntity updateTier(@PathVariable String name, @RequestBo return ResponseEntity.notFound().build(); } + var tierType = TierType.valueOf(tier.getTierType()); + if (tierType != TierType.NON_FREE) { + var existingTiers = repositories.findTiersByTierTypeExcludingTier(TierType.valueOf(tier.getTierType()), savedTier); + if (!existingTiers.isEmpty()) { + return ResponseEntity.badRequest().body(TierJson.error("Tier with type '" + tier.getTierType() + "' already exists")); + } + } + savedTier.updateFromJson(tier); savedTier = repositories.upsertTier(savedTier); - var result = ResultJson.success("Updated tier '" + savedTier.getName() + "'"); + var result = savedTier.toJson(); + result.setSuccess("Updated tier '" + savedTier.getName() + "'"); admins.logAdminAction(adminUser, result); if (rateLimitService != null) { rateLimitService.evictConfigurationCache(); } - return ResponseEntity.ok(savedTier.toJson()); + return ResponseEntity.ok(result); } catch (Exception exc) { logger.error("failed updating tier {}", name, exc); return ResponseEntity.internalServerError().build(); 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 c6e0e60ea..aeb9211f6 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/Customer.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/Customer.java @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,7 +9,7 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ package org.eclipse.openvsx.entities; import jakarta.persistence.*; diff --git a/server/src/main/java/org/eclipse/openvsx/entities/EnforcementState.java b/server/src/main/java/org/eclipse/openvsx/entities/EnforcementState.java index 33a334b21..ed97cda99 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/EnforcementState.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/EnforcementState.java @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,7 +9,7 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ package org.eclipse.openvsx.entities; public enum EnforcementState { diff --git a/server/src/main/java/org/eclipse/openvsx/entities/RefillStrategy.java b/server/src/main/java/org/eclipse/openvsx/entities/RefillStrategy.java index bd178ecbc..b685a97fc 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/RefillStrategy.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/RefillStrategy.java @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,7 +9,7 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ package org.eclipse.openvsx.entities; public enum RefillStrategy { diff --git a/server/src/main/java/org/eclipse/openvsx/entities/Tier.java b/server/src/main/java/org/eclipse/openvsx/entities/Tier.java index d77d00575..86b2bfa53 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/Tier.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/Tier.java @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,7 +9,7 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ package org.eclipse.openvsx.entities; import jakarta.persistence.*; @@ -21,9 +21,6 @@ import java.util.Objects; @Entity -@Table(uniqueConstraints = { - @UniqueConstraint(columnNames = { "name" }), -}) public class Tier implements Serializable { @Serial @@ -34,11 +31,15 @@ public class Tier implements Serializable { @SequenceGenerator(name = "tierSeq", sequenceName = "tier_seq") private long id; - @Column(nullable = false) + @Column(nullable = false, unique = true) private String name; private String description; + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private TierType tierType = TierType.NON_FREE; + @Column(nullable = false) private int capacity; @@ -50,6 +51,10 @@ public class Tier implements Serializable { @Enumerated(EnumType.STRING) private RefillStrategy refillStrategy = RefillStrategy.GREEDY; + public long getId() { + return id; + } + public String getName() { return name; } @@ -66,6 +71,14 @@ public void setDescription(String description) { this.description = description; } + public TierType getTierType() { + return tierType; + } + + public void setTierType(TierType tierType) { + this.tierType = tierType; + } + public int getCapacity() { return capacity; } @@ -91,25 +104,34 @@ public void setRefillStrategy(RefillStrategy refillStrategy) { } public TierJson toJson() { - return new TierJson(name, description, capacity, duration.toSeconds(), refillStrategy.name()); + var json = new TierJson(); + json.setName(name); + json.setDescription(description); + json.setTierType(tierType.name()); + json.setCapacity(capacity); + json.setDuration(duration.toSeconds()); + json.setRefillStrategy(refillStrategy.name()); + return json; } public Tier updateFromJson(TierJson json) { - setName(json.name()); - setDescription(json.description()); - setCapacity(json.capacity()); - setDuration(Duration.ofSeconds(json.duration())); - setRefillStrategy(RefillStrategy.valueOf(json.refillStrategy())); + setName(json.getName()); + setDescription(json.getDescription()); + setTierType(TierType.valueOf(json.getTierType())); + setCapacity(json.getCapacity()); + setDuration(Duration.ofSeconds(json.getDuration())); + setRefillStrategy(RefillStrategy.valueOf(json.getRefillStrategy())); return this; } public static Tier fromJson(TierJson json) { var tier = new Tier(); - tier.setName(json.name()); - tier.setDescription(json.description()); - tier.setCapacity(json.capacity()); - tier.setDuration(Duration.ofSeconds(json.duration())); - tier.setRefillStrategy(RefillStrategy.valueOf(json.refillStrategy())); + tier.setName(json.getName()); + tier.setDescription(json.getDescription()); + tier.setTierType(TierType.valueOf(json.getTierType())); + tier.setCapacity(json.getCapacity()); + tier.setDuration(Duration.ofSeconds(json.getDuration())); + tier.setRefillStrategy(RefillStrategy.valueOf(json.getRefillStrategy())); return tier; } @@ -121,6 +143,7 @@ public boolean equals(Object o) { return id == that.id && Objects.equals(name, that.name) && Objects.equals(description, that.description) + && Objects.equals(tierType, that.tierType) && Objects.equals(capacity, that.capacity) && Objects.equals(duration, that.duration) && Objects.equals(refillStrategy, that.refillStrategy); @@ -128,13 +151,15 @@ public boolean equals(Object o) { @Override public int hashCode() { - return Objects.hash(id, name, description, capacity, duration, refillStrategy); + return Objects.hash(id, name, description, tierType, capacity, duration, refillStrategy); } @Override public String toString() { return "Tier{" + - "name='" + name + '\'' + + "name='" + name + "'" + + ", description='" + description + "'" + + ", tierType=" + tierType + ", capacity=" + capacity + ", duration=" + duration + ", refillStrategy=" + refillStrategy + diff --git a/server/src/main/java/org/eclipse/openvsx/entities/TierType.java b/server/src/main/java/org/eclipse/openvsx/entities/TierType.java new file mode 100644 index 000000000..3a6687cc2 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/entities/TierType.java @@ -0,0 +1,19 @@ +/****************************************************************************** + * 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; + +public enum TierType { + FREE, + SAFETY, + NON_FREE +} diff --git a/server/src/main/java/org/eclipse/openvsx/entities/UsageStats.java b/server/src/main/java/org/eclipse/openvsx/entities/UsageStats.java index 07250e2e9..35e357873 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/UsageStats.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/UsageStats.java @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,7 +9,7 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ package org.eclipse.openvsx.entities; import jakarta.persistence.*; diff --git a/server/src/main/java/org/eclipse/openvsx/json/TierJson.java b/server/src/main/java/org/eclipse/openvsx/json/TierJson.java index 21ca14870..39063b0d6 100644 --- a/server/src/main/java/org/eclipse/openvsx/json/TierJson.java +++ b/server/src/main/java/org/eclipse/openvsx/json/TierJson.java @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,16 +9,80 @@ * 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 jakarta.validation.constraints.NotNull; @JsonInclude(JsonInclude.Include.NON_NULL) -public record TierJson( - String name, - String description, - int capacity, - long duration, - String refillStrategy -) {} +public class TierJson extends ResultJson { + @NotNull + private String name; + + private String description; + + @NotNull + private String tierType; + + private int capacity; + + private long duration; + + @NotNull + private String refillStrategy; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getTierType() { + return tierType; + } + + public void setTierType(String tierType) { + this.tierType = tierType; + } + + public int getCapacity() { + return capacity; + } + + public void setCapacity(int capacity) { + this.capacity = capacity; + } + + public long getDuration() { + return duration; + } + + public void setDuration(long duration) { + this.duration = duration; + } + + public String getRefillStrategy() { + return refillStrategy; + } + + public void setRefillStrategy(String refillStrategy) { + this.refillStrategy = refillStrategy; + } + + public static TierJson error(String message) { + var json = new TierJson(); + json.setError(message); + return json; + } +} \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java index 6d9fc7485..cc58a81f7 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -676,13 +676,21 @@ public boolean isDeleteAllVersions(String namespaceName, String extensionName, L } public List findAllTiers() { - return tierRepo.findAll(); + return tierRepo.findAllByOrderByIdAsc(); } public Tier findTier(String name) { return tierRepo.findByNameIgnoreCase(name); } + public List findTiersByTierType(TierType tierType) { + return tierRepo.findByTierType(tierType); + } + + public List findTiersByTierTypeExcludingTier(TierType tierType, Tier tier) { + return tierRepo.findByTierTypeAndIdNot(tierType, tier.getId()); + } + public Tier upsertTier(Tier tier) { return tierRepo.save(tier); } diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/TierRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/TierRepository.java index 607c0b133..690dc5205 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/TierRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/TierRepository.java @@ -10,15 +10,20 @@ package org.eclipse.openvsx.repositories; import org.eclipse.openvsx.entities.Tier; +import org.eclipse.openvsx.entities.TierType; import org.springframework.data.repository.Repository; import java.util.List; public interface TierRepository extends Repository { - List findAll(); + List findAllByOrderByIdAsc(); Tier findByNameIgnoreCase(String name); + List findByTierType(TierType tierType); + + List findByTierTypeAndIdNot(TierType tierType, long id); + Tier save(Tier tier); void delete(Tier tier); diff --git a/server/src/main/resources/db/migration/V1_58__Rate_Limit.sql b/server/src/main/resources/db/migration/V1_58__Rate_Limit.sql index 71b55e6f4..cc04a386f 100644 --- a/server/src/main/resources/db/migration/V1_58__Rate_Limit.sql +++ b/server/src/main/resources/db/migration/V1_58__Rate_Limit.sql @@ -2,6 +2,7 @@ CREATE TABLE IF NOT EXISTS public.tier (id BIGINT NOT NULL, name CHARACTER VARYING(255) NOT NULL, description CHARACTER VARYING(255), + tier_type CHARACTER VARYING(255) NOT NULL, capacity INTEGER NOT NULL, duration INTEGER NOT NULL, refill_strategy CHARACTER VARYING(255) NOT NULL @@ -16,6 +17,8 @@ ALTER TABLE ONLY public.tier ALTER TABLE ONLY public.tier ADD CONSTRAINT tier_unique_name UNIQUE (name); +CREATE INDEX IF NOT EXISTS tier_tier_type ON tier (tier_type); + -- create customer table CREATE TABLE IF NOT EXISTS public.customer (id BIGINT NOT NULL, name CHARACTER VARYING(255) NOT NULL, diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index 2be466c47..fe27c8613 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -259,14 +259,21 @@ export type MembershipRole = 'contributor' | 'owner'; export type SortBy = 'relevance' | 'timestamp' | 'rating' | 'downloadCount'; export type SortOrder = 'asc' | 'desc'; +export enum TierType { + FREE = 'FREE', + SAFETY = 'SAFETY', + NON_FREE = 'NON_FREE' +} + export enum RefillStrategy { GREEDY = 'GREEDY', - INTERVAL = 'INTERVAL', + INTERVAL = 'INTERVAL' } export interface Tier { name: string; description?: string; + tierType: TierType; capacity: number; duration: number; refillStrategy: RefillStrategy; diff --git a/webui/src/pages/admin-dashboard/admin-dashboard.tsx b/webui/src/pages/admin-dashboard/admin-dashboard.tsx index 2a1a9a4dd..427101cfa 100644 --- a/webui/src/pages/admin-dashboard/admin-dashboard.tsx +++ b/webui/src/pages/admin-dashboard/admin-dashboard.tsx @@ -78,7 +78,7 @@ export const AdminDashboard: FunctionComponent = props => { - + } /> } /> diff --git a/webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx b/webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx index d9f13b7ed..698f1dc69 100644 --- a/webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx +++ b/webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,7 +9,7 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ import React, { FC, useState } from 'react'; import { diff --git a/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx b/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx index 76d65306c..36c80fbf8 100644 --- a/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx +++ b/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,28 +9,28 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ -import React, { FC, useState, useEffect } from 'react'; +import React, {FC, useEffect, useState} from 'react'; +import type {SelectChangeEvent} from '@mui/material'; import { + Alert, + Box, + Button, + CircularProgress, Dialog, - DialogTitle, - DialogContent, DialogActions, - TextField, - Button, + DialogContent, + DialogTitle, FormControl, + FormHelperText, InputLabel, - Select, MenuItem, - CircularProgress, - Alert, - Box, - FormHelperText + Select, + TextField } from '@mui/material'; -import type { SelectChangeEvent } from '@mui/material'; -import { RefillStrategy, type Tier } from "../../../extension-registry-types"; -import { handleError } from "../../../utils"; +import {RefillStrategy, type Tier, TierType} from "../../../extension-registry-types"; +import {handleError} from "../../../utils"; type DurationUnit = 'seconds' | 'minutes' | 'hours'; @@ -84,9 +84,10 @@ export const TierFormDialog: FC = ({ open, tier, onClose, o setFormData(_ => ({ name: tier.name, description: tier.description || '', + tierType: tier.tierType, capacity: tier.capacity, duration: tier.duration, - refillStrategy: tier.refillStrategy as any + refillStrategy: tier.refillStrategy } as Tier)); // Convert duration seconds to value/unit for display const [value, unit] = formatDuration(tier.duration); @@ -97,6 +98,7 @@ export const TierFormDialog: FC = ({ open, tier, onClose, o ...prev, name: '', description: '', + tierType: TierType.NON_FREE, capacity: 100, duration: 3600, refillStrategy: RefillStrategy.INTERVAL @@ -135,7 +137,16 @@ export const TierFormDialog: FC = ({ open, tier, onClose, o }; const fieldValidators: Record string | undefined> = { - name: () => formData.name.trim() ? undefined : 'Tier name is required', + name: () => { + if (formData.name === undefined) { + return "Tier name is required"; + } else if (formData.name.trim() !== formData.name) { + return "Tier name must not contain trailing whitespace"; + } else { + return undefined; + } + }, + tierType: () => formData.tierType ? undefined : 'Tier type is required', capacity: () => formData.capacity <= 0 ? 'Capacity must be greater than 0' : undefined, duration: () => durationValue <= 0 ? 'Duration must be greater than 0' : undefined, refillStrategy: () => formData.refillStrategy ? undefined : 'Refill strategy is required', @@ -155,27 +166,18 @@ export const TierFormDialog: FC = ({ open, tier, onClose, o // Mark all fields as touched on submit setTouched({ name: true, + tierType: true, capacity: true, duration: true, refillStrategy: true, }); const newErrors: Record = {}; - - if (!formData.name.trim()) { - newErrors.name = 'Tier name is required'; - } - - if (formData.capacity <= 0) { - newErrors.capacity = 'Capacity must be greater than 0'; - } - - if (durationValue <= 0) { - newErrors.duration = 'Duration must be greater than 0'; - } - - if (!formData.refillStrategy) { - newErrors.refillStrategy = 'Refill strategy is required'; + for (const key of Object.keys(formData)) { + const error = validateField(key); + if (error !== undefined) { + newErrors[key] = error; + } } setErrors(newErrors); @@ -239,6 +241,34 @@ export const TierFormDialog: FC = ({ open, tier, onClose, o disabled={loading} /> + + Tier Type + + {touched.tierType && errors.tierType && {errors.tierType}} + + { }; // Extract unique values for filter dropdowns + const tierTypeOptions = useMemo(() => + [...new Set(tiers.map(t => t.tierType).filter(Boolean))], + [tiers] + ); + const refillStrategyOptions = useMemo(() => [...new Set(tiers.map(t => t.refillStrategy).filter(Boolean))], [tiers] @@ -124,6 +129,12 @@ export const Tiers: FC = () => { minWidth: 200, valueGetter: (value: string) => value || '-' }, + { + field: 'tierType', + headerName: 'Tier Type', + width: 150, + filterOperators: createMultiSelectFilterOperators(tierTypeOptions) + }, { field: 'capacity', headerName: 'Capacity', From f6bb3aaef08c6d0472bc08d0c92b4b073598e218 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Mon, 2 Feb 2026 10:54:49 +0100 Subject: [PATCH 37/45] update customer handling, improve error messages, only allow non-free tiers to be selected --- .../eclipse/openvsx/admin/RateLimitAPI.java | 26 +++++--- .../eclipse/openvsx/entities/Customer.java | 23 ++++--- .../eclipse/openvsx/json/CustomerJson.java | 60 ++++++++++++++++--- .../openvsx/json/CustomerListJson.java | 4 +- .../eclipse/openvsx/json/TierListJson.java | 4 +- .../customers/customer-form-dialog.tsx | 40 ++++++------- .../admin-dashboard/customers/customers.tsx | 5 +- .../customers/delete-customer-dialog.tsx | 5 +- 8 files changed, 112 insertions(+), 55 deletions(-) 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 9d0eaa4a9..38befe667 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java @@ -222,27 +222,32 @@ public ResponseEntity createCustomer(@RequestBody CustomerJson cus try { var adminUser = admins.checkAdminUser(); - var existingCustomer = repositories.findCustomer(customerJson.name()); + var existingCustomer = repositories.findCustomer(customerJson.getName()); if (existingCustomer != null) { - return ResponseEntity.badRequest().build(); + return ResponseEntity + .badRequest() + .body(CustomerJson.error("Customer with name " + customerJson.getName() + " already exists")); } var customer = Customer.fromJson(customerJson); // resolve the tier reference var tier = repositories.findTier(customer.getTier().getName()); if (tier == null) { - return ResponseEntity.badRequest().build(); + return ResponseEntity + .badRequest() + .body(CustomerJson.error("Tier with name " + customer.getTier().getName() + " does not exist")); } customer.setTier(tier); var savedCustomer = repositories.upsertCustomer(customer); - var result = ResultJson.success("Created customer '" + savedCustomer.getName() + "'"); + var result = savedCustomer.toJson(); + result.setSuccess("Created customer '" + savedCustomer.getName() + "'"); admins.logAdminAction(adminUser, result); - return ResponseEntity.ok(savedCustomer.toJson()); + return ResponseEntity.ok(result); } catch (Exception exc) { - logger.error("failed creating customer {}", customerJson.name(), exc); + logger.error("failed creating customer {}", customerJson.getName(), exc); return ResponseEntity.internalServerError().build(); } } @@ -265,16 +270,19 @@ public ResponseEntity updateCustomer(@PathVariable String name, @R // update the tier reference in case it changed var tier = repositories.findTier(savedCustomer.getTier().getName()); if (tier == null) { - return ResponseEntity.badRequest().build(); + return ResponseEntity + .badRequest() + .body(CustomerJson.error("Tier with name " + customer.getTier().getName() + " does not exist")); } savedCustomer.setTier(tier); savedCustomer = repositories.upsertCustomer(savedCustomer); - var result = ResultJson.success("Updated customer '" + savedCustomer.getName() + "'"); + var result = savedCustomer.toJson(); + result.setSuccess("Updated customer '" + savedCustomer.getName() + "'"); admins.logAdminAction(adminUser, result); - return ResponseEntity.ok(savedCustomer.toJson()); + return ResponseEntity.ok(result); } catch (Exception exc) { logger.error("failed updating tier {}", name, exc); return ResponseEntity.internalServerError().build(); 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 aeb9211f6..774d7221b 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/Customer.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/Customer.java @@ -91,23 +91,28 @@ public void setCidrBlocks(List cidrBlocks) { } public CustomerJson toJson() { - return new CustomerJson(name, tier.toJson(), state.name(), cidrBlocks); + var json = new CustomerJson(); + json.setName(name); + json.setTier(tier.toJson()); + json.setState(state.name()); + json.setCidrBlocks(cidrBlocks); + return json; } public Customer updateFromJson(CustomerJson json) { - setName(json.name()); - setTier(Tier.fromJson(json.tier())); - setState(EnforcementState.valueOf(json.state())); - setCidrBlocks(json.cidrBlocks()); + setName(json.getName()); + setTier(Tier.fromJson(json.getTier())); + setState(EnforcementState.valueOf(json.getState())); + setCidrBlocks(json.getCidrBlocks()); return this; } public static Customer fromJson(CustomerJson json) { var customer = new Customer(); - customer.setName(json.name()); - customer.setTier(Tier.fromJson(json.tier())); - customer.setState(EnforcementState.valueOf(json.state())); - customer.setCidrBlocks(json.cidrBlocks()); + customer.setName(json.getName()); + customer.setTier(Tier.fromJson(json.getTier())); + customer.setState(EnforcementState.valueOf(json.getState())); + customer.setCidrBlocks(json.getCidrBlocks()); return customer; } diff --git a/server/src/main/java/org/eclipse/openvsx/json/CustomerJson.java b/server/src/main/java/org/eclipse/openvsx/json/CustomerJson.java index 051b64634..52f3e605b 100644 --- a/server/src/main/java/org/eclipse/openvsx/json/CustomerJson.java +++ b/server/src/main/java/org/eclipse/openvsx/json/CustomerJson.java @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,17 +9,61 @@ * 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 jakarta.validation.constraints.NotNull; import java.util.List; @JsonInclude(JsonInclude.Include.NON_NULL) -public record CustomerJson( - String name, - TierJson tier, - String state, - List cidrBlocks -) {} +public class CustomerJson extends ResultJson { + @NotNull + private String name; + + private TierJson tier; + + @NotNull + private String state; + + private List cidrBlocks; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public TierJson getTier() { + return tier; + } + + public void setTier(TierJson tier) { + this.tier = tier; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public List getCidrBlocks() { + return cidrBlocks; + } + + public void setCidrBlocks(List cidrBlocks) { + this.cidrBlocks = cidrBlocks; + } + + public static CustomerJson error(String message) { + var json = new CustomerJson(); + json.setError(message); + return json; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/json/CustomerListJson.java b/server/src/main/java/org/eclipse/openvsx/json/CustomerListJson.java index 2b6e60ec3..a20620fb3 100644 --- a/server/src/main/java/org/eclipse/openvsx/json/CustomerListJson.java +++ b/server/src/main/java/org/eclipse/openvsx/json/CustomerListJson.java @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,7 +9,7 @@ * 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; diff --git a/server/src/main/java/org/eclipse/openvsx/json/TierListJson.java b/server/src/main/java/org/eclipse/openvsx/json/TierListJson.java index d158bee6f..ba3d958ae 100644 --- a/server/src/main/java/org/eclipse/openvsx/json/TierListJson.java +++ b/server/src/main/java/org/eclipse/openvsx/json/TierListJson.java @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,7 +9,7 @@ * 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; 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 dd4669d69..7be7d947c 100644 --- a/webui/src/pages/admin-dashboard/customers/customer-form-dialog.tsx +++ b/webui/src/pages/admin-dashboard/customers/customer-form-dialog.tsx @@ -1,4 +1,5 @@ -/* + +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,7 +10,7 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ import React, { FC, useState, useEffect, useRef } from 'react'; import { @@ -134,7 +135,15 @@ export const CustomerFormDialog: FC = ({ open, customer }; const fieldValidators: Record string | undefined> = { - name: () => formData.name.trim() ? undefined : 'Customer name is required', + name: () => { + if (formData.name === undefined) { + return "Customer name is required"; + } else if (formData.name.trim() !== formData.name) { + return "Customer name must not contain trailing whitespace"; + } else { + return undefined; + } + }, tierName: () => formData.tier?.name ? undefined : 'Tier selection is required', state: () => formData.state ? undefined : 'State is required', cidrBlocks: () => { @@ -195,23 +204,10 @@ export const CustomerFormDialog: FC = ({ open, customer }); const newErrors: Record = {}; - - if (!formData.name.trim()) { - newErrors.name = 'Customer name is required'; - } - - if (!formData.tier?.name) { - newErrors.tierName = 'Tier selection is required'; - } - - if (!formData.state) { - newErrors.state = 'State is required'; - } - - if (formData.cidrBlocks && formData.cidrBlocks.length > 0) { - const invalidEntries = formData.cidrBlocks.filter(cidr => !isValidCIDR(cidr.trim())); - if (invalidEntries.length > 0) { - newErrors.cidrBlocks = `Invalid CIDR block(s): ${invalidEntries.join(', ')}`; + for (const key of Object.keys(formData)) { + const error = validateField(key); + if (error !== undefined) { + newErrors[key] = error; } } @@ -274,7 +270,9 @@ export const CustomerFormDialog: FC = ({ open, customer }} label='Tier' > - {tiers.map(tier => ( + {tiers + .filter(tier => tier.tierType === 'NON_FREE') + .map(tier => ( {tier.name} diff --git a/webui/src/pages/admin-dashboard/customers/customers.tsx b/webui/src/pages/admin-dashboard/customers/customers.tsx index 75bfdd4de..641d43031 100644 --- a/webui/src/pages/admin-dashboard/customers/customers.tsx +++ b/webui/src/pages/admin-dashboard/customers/customers.tsx @@ -1,4 +1,5 @@ -/* + +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,7 +10,7 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ import React, { FC, useState, useEffect, useRef, useCallback, useMemo } from "react"; import { diff --git a/webui/src/pages/admin-dashboard/customers/delete-customer-dialog.tsx b/webui/src/pages/admin-dashboard/customers/delete-customer-dialog.tsx index a1f552eac..6456784af 100644 --- a/webui/src/pages/admin-dashboard/customers/delete-customer-dialog.tsx +++ b/webui/src/pages/admin-dashboard/customers/delete-customer-dialog.tsx @@ -1,4 +1,5 @@ -/* + +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,7 +10,7 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ import React, { FC, useState } from 'react'; import { From 01bf126525416137e7db2ccf3e61828a880d425f Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Mon, 2 Feb 2026 14:59:41 +0100 Subject: [PATCH 38/45] refactor ratelimit stuff, support config updates --- .../eclipse/openvsx/admin/RateLimitAPI.java | 15 +- .../openvsx/ratelimit/CustomerService.java | 31 +++- .../openvsx/ratelimit/IdentityService.java | 13 +- .../openvsx/ratelimit/RateLimitService.java | 56 +++--- .../openvsx/ratelimit/ResolvedIdentity.java | 11 +- .../openvsx/ratelimit/UsageDataService.java | 14 +- .../cache/RateLimitCacheService.java | 165 ++++++++++++++++++ ...n.java => RateLimitAutoConfiguration.java} | 35 ++-- ...eLimitConfig.java => RateLimitConfig.java} | 61 +++---- ...es.java => RateLimitFilterProperties.java} | 10 +- ...operties.java => RateLimitProperties.java} | 16 +- .../config/ScheduleRateLimitJobs.java | 4 +- ...ilter.java => RateLimitServletFilter.java} | 18 +- ...ava => RateLimitServletFilterFactory.java} | 18 +- .../jobs/CollectUsageStatsJobRequest.java | 4 +- .../CollectUsageStatsJobRequestHandler.java | 7 +- 16 files changed, 322 insertions(+), 156 deletions(-) create mode 100644 server/src/main/java/org/eclipse/openvsx/ratelimit/cache/RateLimitCacheService.java rename server/src/main/java/org/eclipse/openvsx/ratelimit/config/{TieredRateLimitAutoConfiguration.java => RateLimitAutoConfiguration.java} (63%) rename server/src/main/java/org/eclipse/openvsx/ratelimit/config/{TieredRateLimitConfig.java => RateLimitConfig.java} (72%) rename server/src/main/java/org/eclipse/openvsx/ratelimit/config/{TieredRateLimitFilterProperties.java => RateLimitFilterProperties.java} (90%) rename server/src/main/java/org/eclipse/openvsx/ratelimit/config/{TieredRateLimitProperties.java => RateLimitProperties.java} (80%) rename server/src/main/java/org/eclipse/openvsx/ratelimit/filter/{TieredRateLimitServletFilter.java => RateLimitServletFilter.java} (85%) rename server/src/main/java/org/eclipse/openvsx/ratelimit/filter/{TieredRateLimitServletFilterFactory.java => RateLimitServletFilterFactory.java} (65%) 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 38befe667..0ee433d49 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java @@ -17,6 +17,7 @@ import org.eclipse.openvsx.entities.TierType; import org.eclipse.openvsx.json.*; import org.eclipse.openvsx.ratelimit.RateLimitService; +import org.eclipse.openvsx.ratelimit.cache.RateLimitCacheService; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.util.ErrorResultException; import org.slf4j.Logger; @@ -39,16 +40,16 @@ public class RateLimitAPI { private final RepositoryService repositories; private final AdminService admins; - private RateLimitService rateLimitService; + private RateLimitCacheService rateLimitCacheService; public RateLimitAPI( RepositoryService repositories, AdminService admins, - Optional rateLimitService + Optional rateLimitCacheService ) { this.repositories = repositories; this.admins = admins; - rateLimitService.ifPresent(service -> this.rateLimitService = service); + rateLimitCacheService.ifPresent(service -> this.rateLimitCacheService = service); } @GetMapping( @@ -133,8 +134,8 @@ public ResponseEntity updateTier(@PathVariable String name, @RequestBo result.setSuccess("Updated tier '" + savedTier.getName() + "'"); admins.logAdminAction(adminUser, result); - if (rateLimitService != null) { - rateLimitService.evictConfigurationCache(); + if (rateLimitCacheService != null) { + rateLimitCacheService.evictConfigurationCache(); } return ResponseEntity.ok(result); @@ -282,6 +283,10 @@ public ResponseEntity updateCustomer(@PathVariable String name, @R result.setSuccess("Updated customer '" + savedCustomer.getName() + "'"); admins.logAdminAction(adminUser, result); + if (rateLimitCacheService != null) { + rateLimitCacheService.evictConfigurationCache(); + } + return ResponseEntity.ok(result); } catch (Exception exc) { logger.error("failed updating tier {}", name, exc); 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 978784ac8..260cd9b07 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerService.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerService.java @@ -1,5 +1,4 @@ - -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -10,23 +9,27 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ package org.eclipse.openvsx.ratelimit; import inet.ipaddr.IPAddressString; import org.eclipse.openvsx.entities.Customer; +import org.eclipse.openvsx.entities.Tier; +import org.eclipse.openvsx.entities.TierType; +import org.eclipse.openvsx.ratelimit.cache.RateLimitCacheService; +import org.eclipse.openvsx.ratelimit.config.RateLimitConfig; import org.eclipse.openvsx.repositories.RepositoryService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.cache.annotation.Cacheable; -import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; -import javax.annotation.Nullable; import java.util.Optional; -import static org.eclipse.openvsx.ratelimit.config.TieredRateLimitConfig.CACHE_RATE_LIMIT_CUSTOMER; -@Component +@Service +@ConditionalOnBean(RateLimitConfig.class) public class CustomerService { private final Logger logger = LoggerFactory.getLogger(CustomerService.class); @@ -37,12 +40,22 @@ public CustomerService(RepositoryService repositories) { this.repositories = repositories; } - @Cacheable(value = CACHE_RATE_LIMIT_CUSTOMER, key = "'id_' + #id", cacheManager = "rateLimitCacheManager") + @Cacheable(value = RateLimitCacheService.CACHE_TIER, cacheManager = RateLimitCacheService.CACHE_MANAGER) + public Optional getFreeTier() { + var freeTiers = repositories.findTiersByTierType(TierType.FREE); + if (freeTiers.isEmpty()) { + return Optional.of(freeTiers.getFirst()); + } else { + return Optional.empty(); + } + } + + @Cacheable(value = RateLimitCacheService.CACHE_CUSTOMER, key = "'id_' + #id", cacheManager = RateLimitCacheService.CACHE_MANAGER) public Optional getCustomerById(long id) { return repositories.findCustomerById(id); } - @Cacheable(value = CACHE_RATE_LIMIT_CUSTOMER, cacheManager = "rateLimitCacheManager") + @Cacheable(value = RateLimitCacheService.CACHE_CUSTOMER, cacheManager = RateLimitCacheService.CACHE_MANAGER) public Optional getCustomerByIpAddress(String ipAddress) { for (Customer customer : repositories.findAllCustomers()) { for (String cidrBlock : customer.getCidrBlocks()) { diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/IdentityService.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/IdentityService.java index ea42acfe3..8aa2d39c9 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/IdentityService.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/IdentityService.java @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,13 +9,16 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ package org.eclipse.openvsx.ratelimit; import jakarta.servlet.http.HttpServletRequest; -import org.springframework.stereotype.Component; +import org.eclipse.openvsx.ratelimit.config.RateLimitConfig; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.stereotype.Service; -@Component +@Service +@ConditionalOnBean(RateLimitConfig.class) public class IdentityService { private final CustomerService customerService; @@ -53,7 +56,7 @@ public ResolvedIdentity resolveIdentity(HttpServletRequest request) { cacheKey = "ip_" + ipAddress; } - return new ResolvedIdentity(cacheKey, customer.orElse(null)); + return new ResolvedIdentity(cacheKey, customer.orElse(null), customerService.getFreeTier().orElse(null), null); } private String getIPAddress(HttpServletRequest request) { diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/RateLimitService.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/RateLimitService.java index 52d249802..4c9974fd6 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/RateLimitService.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/RateLimitService.java @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,52 +9,39 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ package org.eclipse.openvsx.ratelimit; -import com.giffing.bucket4j.spring.boot.starter.config.cache.CacheUpdateEvent; import io.github.bucket4j.Bandwidth; import io.github.bucket4j.Bucket; import io.github.bucket4j.BucketConfiguration; import io.github.bucket4j.TokensInheritanceStrategy; import io.github.bucket4j.distributed.proxy.ProxyManager; -import org.eclipse.openvsx.cache.JedisClusterCacheManager; import org.eclipse.openvsx.entities.RefillStrategy; -import org.eclipse.openvsx.ratelimit.config.TieredRateLimitConfig; +import org.eclipse.openvsx.ratelimit.cache.RateLimitCacheService; +import org.eclipse.openvsx.ratelimit.config.RateLimitConfig; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; -import org.springframework.cache.CacheManager; -import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; import java.nio.charset.StandardCharsets; import java.time.Duration; -import static org.eclipse.openvsx.ratelimit.config.TieredRateLimitConfig.CACHE_RATE_LIMIT_BUCKET; -@Component -@ConditionalOnBean(TieredRateLimitConfig.class) +@Service +@ConditionalOnBean(RateLimitConfig.class) public class RateLimitService { private final Logger logger = LoggerFactory.getLogger(RateLimitService.class); private final ProxyManager proxyManager; - private final JedisClusterCacheManager cacheManager; - private final CacheManager rateLimitCacheManager; - public RateLimitService( - ProxyManager proxyManager, - JedisClusterCacheManager cacheManager, - @Qualifier("rateLimitCacheManager") CacheManager rateLimitCacheManager) { + public RateLimitService(ProxyManager proxyManager) { this.proxyManager = proxyManager; - this.cacheManager = cacheManager; - this.rateLimitCacheManager = rateLimitCacheManager; } - @Cacheable(value = CACHE_RATE_LIMIT_BUCKET, cacheManager = "rateLimitCacheManager") + @Cacheable(value = RateLimitCacheService.CACHE_BUCKET, cacheManager = RateLimitCacheService.CACHE_MANAGER) public Bucket getBucket(ResolvedIdentity identity) { var bandwidth = getBandwidth(identity); var bucketConfiguration = @@ -82,22 +69,19 @@ private Bandwidth getBandwidth(ResolvedIdentity identity) { return buildStage.build(); } else { - // TODO: get data for free tier from db - return Bandwidth.builder().capacity(10000).refillGreedy(10000, Duration.ofMinutes(5)).build(); - } - } + var freeTier = identity.freeTier(); - @CacheEvict(value = CACHE_RATE_LIMIT_BUCKET, cacheManager = "rateLimitCacheManager", allEntries = true) - public void evictBucketCache() { - logger.info("evict bucket cache"); - } + var fillStage = Bandwidth.builder().capacity(freeTier.getCapacity()); - public void evictConfigurationCache() { - cacheManager.setValue("test", "1"); - } + var buildStage = switch (freeTier.getRefillStrategy()) { + case RefillStrategy.GREEDY -> fillStage.refillGreedy(freeTier.getCapacity(), freeTier.getDuration()); + case RefillStrategy.INTERVAL -> fillStage.refillIntervally(freeTier.getCapacity(), freeTier.getDuration()); + }; + + return buildStage.build(); - @EventListener(CacheUpdateEvent.class) - public void onCacheUpdateEvent(CacheUpdateEvent event) { - evictBucketCache(); + // TODO: get data for free tier from db +// return Bandwidth.builder().capacity(10000).refillGreedy(10000, Duration.ofMinutes(5)).build(); + } } } diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/ResolvedIdentity.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/ResolvedIdentity.java index 26db1d9ab..ded955b00 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/ResolvedIdentity.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/ResolvedIdentity.java @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,19 +9,22 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ package org.eclipse.openvsx.ratelimit; import jakarta.validation.constraints.NotNull; import org.eclipse.openvsx.entities.Customer; +import org.eclipse.openvsx.entities.Tier; import javax.annotation.Nonnull; import javax.annotation.Nullable; public record ResolvedIdentity( @Nonnull String cacheKey, - @Nullable Customer customer -) { + @Nullable Customer customer, + @Nullable Tier freeTier, + @Nullable Tier safetyTier + ) { public boolean isCustomer() { return customer != null; diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/UsageDataService.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/UsageDataService.java index 56e8a1f6b..5bfe907e4 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/UsageDataService.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/UsageDataService.java @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,12 +9,12 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ package org.eclipse.openvsx.ratelimit; import org.eclipse.openvsx.entities.Customer; import org.eclipse.openvsx.entities.UsageStats; -import org.eclipse.openvsx.ratelimit.config.TieredRateLimitConfig; +import org.eclipse.openvsx.ratelimit.config.RateLimitConfig; import org.eclipse.openvsx.repositories.RepositoryService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -31,7 +31,7 @@ import java.util.Map; @Component -@ConditionalOnBean(TieredRateLimitConfig.class) +@ConditionalOnBean(RateLimitConfig.class) public class UsageDataService { // TODO: run the job every 10m public static final String COLLECT_USAGE_STATS_SCHEDULE = "*/15 * * * * *"; @@ -55,7 +55,7 @@ public void incrementUsage(Customer customer) { var key = customer.getId(); var window = getCurrentUsageWindow(); var old = jedisCluster.hincrBy(USAGE_DATA_KEY, key + ":" + window, 1); - logger.info("tiered-rate-limit: usage count for {}: {}", customer.getName(), old + 1); + logger.debug("usage count for {}: {}", customer.getName(), old + 1); } public void persistUsageStats() { @@ -71,7 +71,7 @@ public void persistUsageStats() { var key = result.getKey(); var value = result.getValue(); - logger.debug("tiered-rate-limit: usage stats: {} - {}", key, value); + logger.debug("usage stats: {} - {}", key, value); var component = key.split(":"); var customerId = Long.parseLong(component[0]); @@ -80,7 +80,7 @@ public void persistUsageStats() { if (window < currentWindow) { var customer = customerService.getCustomerById(customerId); if (customer.isEmpty()) { - logger.warn("tiered-rate-limit: failed to find customer with id {}", customerId); + logger.warn("failed to find customer with id {}", customerId); } else { UsageStats stats = new UsageStats(); diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/cache/RateLimitCacheService.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/cache/RateLimitCacheService.java new file mode 100644 index 000000000..dcb6b9512 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/cache/RateLimitCacheService.java @@ -0,0 +1,165 @@ +/****************************************************************************** + * 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.ratelimit.cache; + +import io.micrometer.core.instrument.util.NamedThreadFactory; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import org.eclipse.openvsx.ratelimit.config.RateLimitConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Service; +import redis.clients.jedis.JedisCluster; +import redis.clients.jedis.JedisPubSub; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +@Service +@ConditionalOnBean(RateLimitConfig.class) +public class RateLimitCacheService extends JedisPubSub { + public static final String CACHE_MANAGER = "rateLimitCacheManager"; + + private static final String CONFIG_UPDATE_CHANNEL = "ratelimit.config"; + + public static final String CACHE_CUSTOMER = "ratelimit.customer"; + public static final String CACHE_TIER = "ratelimit.tier"; + public static final String CACHE_TOKEN = "ratelimit.token"; + public static final String CACHE_BUCKET = "ratelimit.bucket"; + + private final Logger logger = LoggerFactory.getLogger(RateLimitCacheService.class); + + private final JedisCluster jedisCluster; + private final CacheManager cacheManager; + private final ConfigCacheUpdateListener configCacheListener; + + public RateLimitCacheService( + JedisCluster jedisCluster, + @Qualifier(CACHE_MANAGER) CacheManager cacheManager + ) { + this.jedisCluster = jedisCluster; + this.cacheManager = cacheManager; + this.configCacheListener = new ConfigCacheUpdateListener(jedisCluster); + } + + @PostConstruct + public void initialize() { + configCacheListener.startSubscriber(); + } + + @PreDestroy + public void shutdown() { + configCacheListener.shutdown(); + } + + public void evictConfigurationCache() { + String version = String.valueOf(System.currentTimeMillis()); + jedisCluster.publish(CONFIG_UPDATE_CHANNEL, version); + } + + public void evictCustomerCache() { + logger.info("evict customer cache"); + var cache = cacheManager.getCache(CACHE_CUSTOMER); + if (cache != null) { + cache.clear(); + } + } + + public void evictTierCache() { + logger.info("evict tier cache"); + var cache = cacheManager.getCache(CACHE_TIER); + if (cache != null) { + cache.clear(); + } + } + + public void evictBucketCache() { + logger.info("evict bucket cache"); + var cache = cacheManager.getCache(CACHE_BUCKET); + if (cache != null) { + cache.clear(); + } + } + + private class ConfigCacheUpdateListener extends JedisPubSub { + private final JedisCluster jedisCluster; + + // Redis subscriber state + private volatile Thread subscriberThread; + private volatile boolean running = true; + + public ConfigCacheUpdateListener(JedisCluster jedisCluster) { + this.jedisCluster = jedisCluster; + } + + void startSubscriber() { + subscriberThread = new Thread(this::subscribeLoop, "RateLimitConfigSubscriber"); + subscriberThread.setDaemon(true); + subscriberThread.start(); + } + + void shutdown() { + running = false; + if (isSubscribed()) { + unsubscribe(); + } + if (subscriberThread != null) { + subscriberThread.interrupt(); + } + } + + private void subscribeLoop() { + AtomicInteger backoffMs = new AtomicInteger(1000); + ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor( + new NamedThreadFactory("rate-limit-config-subscriber-reconnect")); + + while (running && !Thread.currentThread().isInterrupted()) { + ScheduledFuture resetTask = null; + try { + resetTask = executor.schedule(() -> backoffMs.set(1000), 10, TimeUnit.SECONDS); + logger.debug("subscribing to ratelimit config update channel"); + jedisCluster.subscribe(this, CONFIG_UPDATE_CHANNEL); + } catch (Exception e) { + if (!running) break; + logger.warn("Ratelimit config subscriber disconnected, reconnecting in {}s: {}", + backoffMs.get() / 1000, e.getMessage()); + if (resetTask != null) resetTask.cancel(true); + try { + Thread.sleep(backoffMs.get()); + backoffMs.set(Math.min(backoffMs.get() * 2, 30000)); + } catch (InterruptedException ignored) { + break; + } + } + } + executor.shutdownNow(); + } + + @Override + public void onMessage(String channel, String message) { + if (CONFIG_UPDATE_CHANNEL.equals(channel)) { + logger.debug("Received ratelimit config update notification from another pod"); + // TODO: only evict stuff that changed + evictCustomerCache(); + evictTierCache(); + evictBucketCache(); + } + } + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitAutoConfiguration.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/RateLimitAutoConfiguration.java similarity index 63% rename from server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitAutoConfiguration.java rename to server/src/main/java/org/eclipse/openvsx/ratelimit/config/RateLimitAutoConfiguration.java index 910d2038c..d6f542f2e 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitAutoConfiguration.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/RateLimitAutoConfiguration.java @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,41 +9,36 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ package org.eclipse.openvsx.ratelimit.config; -import com.giffing.bucket4j.spring.boot.starter.config.cache.CacheUpdateEvent; -import com.giffing.bucket4j.spring.boot.starter.context.FilterMethod; -import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JConfiguration; -import com.giffing.bucket4j.spring.boot.starter.filter.servlet.ServletRateLimitFilter; -import org.eclipse.openvsx.ratelimit.filter.TieredRateLimitServletFilter; -import org.eclipse.openvsx.ratelimit.filter.TieredRateLimitServletFilterFactory; +import org.eclipse.openvsx.ratelimit.filter.RateLimitServletFilter; +import org.eclipse.openvsx.ratelimit.filter.RateLimitServletFilterFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.web.server.WebServerFactoryCustomizer; import org.springframework.boot.web.servlet.server.ConfigurableServletWebServerFactory; import org.springframework.context.annotation.Configuration; -import org.springframework.context.event.EventListener; import org.springframework.context.support.GenericApplicationContext; import org.springframework.util.StringUtils; import java.util.concurrent.atomic.AtomicInteger; @Configuration -@ConditionalOnBean(TieredRateLimitConfig.class) -public class TieredRateLimitAutoConfiguration implements WebServerFactoryCustomizer { +@ConditionalOnBean(RateLimitConfig.class) +public class RateLimitAutoConfiguration implements WebServerFactoryCustomizer { - private final Logger logger = LoggerFactory.getLogger(TieredRateLimitAutoConfiguration.class); + private final Logger logger = LoggerFactory.getLogger(RateLimitAutoConfiguration.class); private final GenericApplicationContext context; - private final TieredRateLimitProperties properties; - private final TieredRateLimitServletFilterFactory filterFactory; + private final RateLimitProperties properties; + private final RateLimitServletFilterFactory filterFactory; - public TieredRateLimitAutoConfiguration( + public RateLimitAutoConfiguration( GenericApplicationContext context, - TieredRateLimitProperties properties, - TieredRateLimitServletFilterFactory filterFactory + RateLimitProperties properties, + RateLimitServletFilterFactory filterFactory ) { this.context = context; this.properties = properties; @@ -62,14 +57,14 @@ public void customize(ConfigurableServletWebServerFactory factory) { var beanName = ("tieredRateLimitServletRequestFilter" + filterCount); context.registerBean( beanName, - TieredRateLimitServletFilter.class, + RateLimitServletFilter.class, () -> filterFactory.create(filter)); - logger.debug("tiered-rate-limit:create-servlet-filter;{};{}", filterCount, filter.getUrl()); + logger.info("rate-limit:create-servlet-filter;{};{}", filterCount, filter.getUrl()); }); } - private void setDefaults(TieredRateLimitFilterProperties filterProperties) { + private void setDefaults(RateLimitFilterProperties filterProperties) { if (!StringUtils.hasLength(filterProperties.getHttpResponseBody())) { filterProperties.setHttpResponseBody(properties.getDefaultHttpResponseBody()); } diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/RateLimitConfig.java similarity index 72% rename from server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java rename to server/src/main/java/org/eclipse/openvsx/ratelimit/config/RateLimitConfig.java index e53a4b463..f8cfd7dc4 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/RateLimitConfig.java @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,11 +9,9 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ package org.eclipse.openvsx.ratelimit.config; -import com.giffing.bucket4j.spring.boot.starter.config.condition.ConditionalOnFilterConfigCacheEnabled; -import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JConfiguration; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.github.benmanes.caffeine.cache.Scheduler; @@ -22,8 +20,6 @@ import io.github.bucket4j.distributed.proxy.ProxyManager; import io.github.bucket4j.redis.jedis.cas.JedisBasedProxyManager; import io.micrometer.common.util.StringUtils; -import org.eclipse.openvsx.cache.JedisClusterCacheListener; -import org.eclipse.openvsx.cache.JedisClusterCacheManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; @@ -34,7 +30,6 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.cache.CacheManager; import org.springframework.cache.caffeine.CaffeineCacheManager; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import redis.clients.jedis.DefaultJedisClientConfig; @@ -44,22 +39,18 @@ import java.time.Duration; import java.util.stream.Collectors; -@Configuration -@ConditionalOnProperty(prefix = TieredRateLimitProperties.PROPERTY_PREFIX, name = "enabled", havingValue = "true") -@EnableConfigurationProperties({TieredRateLimitProperties.class}) -public class TieredRateLimitConfig { - - private final Logger logger = LoggerFactory.getLogger(TieredRateLimitConfig.class); +import static org.eclipse.openvsx.ratelimit.cache.RateLimitCacheService.*; - public static final String CONFIG_CACHE = "ratelimit.config"; +@Configuration +@ConditionalOnProperty(prefix = RateLimitProperties.PROPERTY_PREFIX, name = "enabled", havingValue = "true") +@EnableConfigurationProperties({RateLimitProperties.class}) +public class RateLimitConfig { - public static final String CACHE_RATE_LIMIT_CUSTOMER = "ratelimit.customer"; - public static final String CACHE_RATE_LIMIT_TOKEN = "ratelimit.token"; - public static final String CACHE_RATE_LIMIT_BUCKET = "ratelimit.bucket"; + private final Logger logger = LoggerFactory.getLogger(RateLimitConfig.class); @Bean public JedisCluster jedisCluster(RedisProperties properties) { - logger.info("Configure jedis-cluster rate-limiting cache"); + logger.info("configure jedis-cluster rate-limiting cache"); var configBuilder = DefaultJedisClientConfig.builder(); var username = properties.getUsername(); if(StringUtils.isNotEmpty(username)) { @@ -78,22 +69,22 @@ public JedisCluster jedisCluster(RedisProperties properties) { } @Bean - public JedisClusterCacheManager configCacheManager(JedisCluster jedisCluster) { - return new JedisClusterCacheManager<>(jedisCluster, CONFIG_CACHE, String.class); - } - - @Bean - public JedisClusterCacheListener configCacheListener( - JedisCluster jedisCluster, - ApplicationEventPublisher eventPublisher + public Cache customerCache( + @Value("${ovsx.caching.customer.tti:PT1H}") Duration timeToIdle, + @Value("${ovsx.caching.customer.max-size:10000}") long maxSize ) { - return new JedisClusterCacheListener<>(jedisCluster, CONFIG_CACHE, String.class, String.class, eventPublisher); + return Caffeine.newBuilder() + .expireAfterAccess(timeToIdle) + .maximumSize(maxSize) + .scheduler(Scheduler.systemScheduler()) + .recordStats() + .build(); } @Bean - public Cache customerCache( - @Value("${ovsx.caching.customer.tti:PT1H}") Duration timeToIdle, - @Value("${ovsx.caching.customer.max-size:10000}") long maxSize + public Cache tierCache( + @Value("${ovsx.caching.tier.tti:PT1H}") Duration timeToIdle, + @Value("${ovsx.caching.tier.max-size:10000}") long maxSize ) { return Caffeine.newBuilder() .expireAfterAccess(timeToIdle) @@ -130,17 +121,19 @@ public Cache bucketCache( } @Bean - @Qualifier("rateLimitCacheManager") + @Qualifier(CACHE_MANAGER) public CacheManager rateLimitCacheManager( Cache customerCache, + Cache tierCache, Cache tokenCache, Cache bucketCache ) { logger.info("Configure rate limit cache manager"); CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager(); - caffeineCacheManager.registerCustomCache(CACHE_RATE_LIMIT_CUSTOMER, customerCache); - caffeineCacheManager.registerCustomCache(CACHE_RATE_LIMIT_TOKEN, tokenCache); - caffeineCacheManager.registerCustomCache(CACHE_RATE_LIMIT_BUCKET, bucketCache); + caffeineCacheManager.registerCustomCache(CACHE_CUSTOMER, customerCache); + caffeineCacheManager.registerCustomCache(CACHE_TIER, tierCache); + caffeineCacheManager.registerCustomCache(CACHE_TOKEN, tokenCache); + caffeineCacheManager.registerCustomCache(CACHE_BUCKET, bucketCache); return caffeineCacheManager; } diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitFilterProperties.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/RateLimitFilterProperties.java similarity index 90% rename from server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitFilterProperties.java rename to server/src/main/java/org/eclipse/openvsx/ratelimit/config/RateLimitFilterProperties.java index 41a180cde..38bfff3c4 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitFilterProperties.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/RateLimitFilterProperties.java @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,9 +9,10 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ package org.eclipse.openvsx.ratelimit.config; +import java.io.Serial; import java.io.Serializable; import java.util.HashMap; import java.util.Map; @@ -25,7 +26,10 @@ import com.fasterxml.jackson.annotation.JsonIgnore; -public class TieredRateLimitFilterProperties implements Serializable { +public class RateLimitFilterProperties implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; /** * The URL to which the filter should be registered diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitProperties.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/RateLimitProperties.java similarity index 80% rename from server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitProperties.java rename to server/src/main/java/org/eclipse/openvsx/ratelimit/config/RateLimitProperties.java index 31c151b0e..091e629a6 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/TieredRateLimitProperties.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/RateLimitProperties.java @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,7 +9,7 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ package org.eclipse.openvsx.ratelimit.config; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -22,10 +22,10 @@ import java.util.ArrayList; import java.util.List; -@ConfigurationProperties(prefix = TieredRateLimitProperties.PROPERTY_PREFIX) -public class TieredRateLimitProperties { +@ConfigurationProperties(prefix = RateLimitProperties.PROPERTY_PREFIX) +public class RateLimitProperties { - public static final String PROPERTY_PREFIX = "ovsx.tiered-rate-limit"; + public static final String PROPERTY_PREFIX = "ovsx.rate-limit"; /** * Enables or disables the tiered rate limit mechanism. @@ -34,7 +34,7 @@ public class TieredRateLimitProperties { private Boolean enabled = false; @Valid - private List filters = new ArrayList<>(); + private List filters = new ArrayList<>(); @NotBlank private String defaultHttpContentType = "application/json"; @@ -55,11 +55,11 @@ public void setEnabled(boolean enabled) { this.enabled = enabled; } - public List getFilters() { + public List getFilters() { return filters; } - public void setFilters(List filters) { + public void setFilters(List filters) { this.filters = filters; } diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/ScheduleRateLimitJobs.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/ScheduleRateLimitJobs.java index 725c1faae..e398dfff5 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/config/ScheduleRateLimitJobs.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/ScheduleRateLimitJobs.java @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,7 +9,7 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ package org.eclipse.openvsx.ratelimit.config; import org.eclipse.openvsx.ratelimit.UsageDataService; diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/RateLimitServletFilter.java similarity index 85% rename from server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java rename to server/src/main/java/org/eclipse/openvsx/ratelimit/filter/RateLimitServletFilter.java index 072c7bb86..22adb080d 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilter.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/RateLimitServletFilter.java @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,7 +9,7 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ package org.eclipse.openvsx.ratelimit.filter; import io.github.bucket4j.ConsumptionProbe; @@ -19,7 +19,7 @@ import org.eclipse.openvsx.ratelimit.RateLimitService; import org.eclipse.openvsx.ratelimit.UsageDataService; import org.eclipse.openvsx.ratelimit.IdentityService; -import org.eclipse.openvsx.ratelimit.config.TieredRateLimitFilterProperties; +import org.eclipse.openvsx.ratelimit.config.RateLimitFilterProperties; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.Ordered; @@ -28,17 +28,17 @@ import java.io.IOException; import java.util.concurrent.TimeUnit; -public class TieredRateLimitServletFilter extends OncePerRequestFilter implements Ordered { +public class RateLimitServletFilter extends OncePerRequestFilter implements Ordered { - private final Logger logger = LoggerFactory.getLogger(TieredRateLimitServletFilter.class); + private final Logger logger = LoggerFactory.getLogger(RateLimitServletFilter.class); - private final TieredRateLimitFilterProperties filterProperties; + private final RateLimitFilterProperties filterProperties; private final UsageDataService customerUsageService; private final IdentityService identityService; private final RateLimitService rateLimitService; - public TieredRateLimitServletFilter( - TieredRateLimitFilterProperties filterProperties, + public RateLimitServletFilter( + RateLimitFilterProperties filterProperties, UsageDataService customerUsageService, IdentityService identityService, RateLimitService rateLimitService @@ -62,7 +62,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse if (identity.isCustomer()) { var customer = identity.getCustomer(); - logger.info("tiered-rate-limit: updating usage status for customer {}", customer.getName()); + logger.info("updating usage status for customer {}", customer.getName()); customerUsageService.incrementUsage(customer); } diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/RateLimitServletFilterFactory.java similarity index 65% rename from server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java rename to server/src/main/java/org/eclipse/openvsx/ratelimit/filter/RateLimitServletFilterFactory.java index fc0fda8d2..4748e83e5 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/TieredRateLimitServletFilterFactory.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/RateLimitServletFilterFactory.java @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,25 +9,25 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ package org.eclipse.openvsx.ratelimit.filter; import org.eclipse.openvsx.ratelimit.RateLimitService; import org.eclipse.openvsx.ratelimit.UsageDataService; import org.eclipse.openvsx.ratelimit.IdentityService; -import org.eclipse.openvsx.ratelimit.config.TieredRateLimitConfig; -import org.eclipse.openvsx.ratelimit.config.TieredRateLimitFilterProperties; +import org.eclipse.openvsx.ratelimit.config.RateLimitConfig; +import org.eclipse.openvsx.ratelimit.config.RateLimitFilterProperties; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.stereotype.Component; @Component -@ConditionalOnBean(TieredRateLimitConfig.class) -public class TieredRateLimitServletFilterFactory { +@ConditionalOnBean(RateLimitConfig.class) +public class RateLimitServletFilterFactory { private final UsageDataService usageService; private final IdentityService identityService; private final RateLimitService rateLimitService; - public TieredRateLimitServletFilterFactory( + public RateLimitServletFilterFactory( UsageDataService usageService, IdentityService identityService, RateLimitService rateLimitService @@ -37,7 +37,7 @@ public TieredRateLimitServletFilterFactory( this.rateLimitService = rateLimitService; } - public TieredRateLimitServletFilter create(TieredRateLimitFilterProperties filterProperties) { - return new TieredRateLimitServletFilter(filterProperties, usageService, identityService, rateLimitService); + public RateLimitServletFilter create(RateLimitFilterProperties filterProperties) { + return new RateLimitServletFilter(filterProperties, usageService, identityService, rateLimitService); } } diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/jobs/CollectUsageStatsJobRequest.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/jobs/CollectUsageStatsJobRequest.java index c4d2f2b8d..ec75e9ac1 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/jobs/CollectUsageStatsJobRequest.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/jobs/CollectUsageStatsJobRequest.java @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,7 +9,7 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ package org.eclipse.openvsx.ratelimit.jobs; import org.jobrunr.jobs.lambdas.JobRequest; diff --git a/server/src/main/java/org/eclipse/openvsx/ratelimit/jobs/CollectUsageStatsJobRequestHandler.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/jobs/CollectUsageStatsJobRequestHandler.java index d303ba40a..beb341a6b 100644 --- a/server/src/main/java/org/eclipse/openvsx/ratelimit/jobs/CollectUsageStatsJobRequestHandler.java +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/jobs/CollectUsageStatsJobRequestHandler.java @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,7 +9,7 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ package org.eclipse.openvsx.ratelimit.jobs; import org.eclipse.openvsx.ratelimit.UsageDataService; @@ -35,8 +35,9 @@ public CollectUsageStatsJobRequestHandler(Optional usageDataSe @Job(name = "Collect usage stats") public void run(CollectUsageStatsJobRequest collectUsageStatsJobRequest) throws Exception { if (usageDataService != null) { - logger.info("tiered-rate-limit: starting collect usage stats job"); + logger.info(">> start collecting usage data"); usageDataService.persistUsageStats(); + logger.info("<< finished collecting usage data"); } } } From 96a367d6f396871caf6db185e13538e490ace69e Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Mon, 2 Feb 2026 17:47:23 +0100 Subject: [PATCH 39/45] implement usage stats data --- .../eclipse/openvsx/admin/RateLimitAPI.java | 36 ++++-- .../eclipse/openvsx/entities/Customer.java | 6 +- .../eclipse/openvsx/entities/UsageStats.java | 6 + .../eclipse/openvsx/json/UsageStatsJson.java | 18 +++ .../openvsx/json/UsageStatsListJson.java | 22 ++++ .../repositories/CustomerRepository.java | 13 +- .../repositories/RepositoryService.java | 10 ++ .../openvsx/repositories/TierRepository.java | 13 +- .../repositories/UsageStatsRepository.java | 15 ++- webui/src/extension-registry-service.ts | 44 ++----- webui/src/extension-registry-types.ts | 6 +- .../usage-stats/usage-stats-chart.tsx | 116 ++++++------------ .../usage-stats/usage-stats-search.tsx | 4 +- .../usage-stats/usage-stats-utils.ts | 46 +------ .../usage-stats/usage-stats.tsx | 27 ++-- 15 files changed, 177 insertions(+), 205 deletions(-) create mode 100644 server/src/main/java/org/eclipse/openvsx/json/UsageStatsJson.java create mode 100644 server/src/main/java/org/eclipse/openvsx/json/UsageStatsListJson.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 0ee433d49..e66ddb1d7 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,27 +9,24 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ 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.json.*; -import org.eclipse.openvsx.ratelimit.RateLimitService; import org.eclipse.openvsx.ratelimit.cache.RateLimitCacheService; import org.eclipse.openvsx.repositories.RepositoryService; -import org.eclipse.openvsx.util.ErrorResultException; +import org.eclipse.openvsx.util.TimeUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.dao.DataAccessException; -import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import org.springframework.web.client.HttpClientErrorException; -import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDateTime; import java.util.Optional; @@ -318,4 +315,27 @@ public ResponseEntity deleteCustomer(@PathVariable String name) { return ResponseEntity.internalServerError().build(); } } + + @GetMapping( + path = "/customers/{name}/usage", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity getCustomers(@PathVariable String name, @RequestParam(required = false) String date) { + try { + admins.checkAdminUser(); + + var customer = repositories.findCustomer(name); + if (customer == null) { + return ResponseEntity.notFound().build(); + } + + var localDateTime = date != null ? TimeUtil.fromUTCString(date) : LocalDateTime.now(); + 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(); + } + } } 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 774d7221b..f26819722 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/Customer.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/Customer.java @@ -14,19 +14,14 @@ import jakarta.persistence.*; import org.eclipse.openvsx.json.CustomerJson; -import org.eclipse.openvsx.json.TierJson; import java.io.Serial; import java.io.Serializable; -import java.time.Duration; import java.util.Collections; import java.util.List; import java.util.Objects; @Entity -@Table(uniqueConstraints = { - @UniqueConstraint(columnNames = { "name" }), -}) public class Customer implements Serializable { @Serial @@ -37,6 +32,7 @@ public class Customer implements Serializable { @SequenceGenerator(name = "customerSeq", sequenceName = "customer_seq") private long id; + @Column(nullable = false, unique = true) private String name; @ManyToOne diff --git a/server/src/main/java/org/eclipse/openvsx/entities/UsageStats.java b/server/src/main/java/org/eclipse/openvsx/entities/UsageStats.java index 35e357873..d3e850c39 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/UsageStats.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/UsageStats.java @@ -13,6 +13,8 @@ package org.eclipse.openvsx.entities; import jakarta.persistence.*; +import org.eclipse.openvsx.json.CustomerJson; +import org.eclipse.openvsx.json.UsageStatsJson; import java.io.Serial; import java.io.Serializable; @@ -87,6 +89,10 @@ public void setCount(long count) { this.count = count; } + public UsageStatsJson toJson() { + return new UsageStatsJson(windowStart.toString(), duration.toSeconds(), count); + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/server/src/main/java/org/eclipse/openvsx/json/UsageStatsJson.java b/server/src/main/java/org/eclipse/openvsx/json/UsageStatsJson.java new file mode 100644 index 000000000..f035143c4 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/UsageStatsJson.java @@ -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 + *****************************************************************************/ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record UsageStatsJson(String windowStart, long duration, long count) {} \ No newline at end of file diff --git a/server/src/main/java/org/eclipse/openvsx/json/UsageStatsListJson.java b/server/src/main/java/org/eclipse/openvsx/json/UsageStatsListJson.java new file mode 100644 index 000000000..f510d6c64 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/UsageStatsListJson.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 java.util.List; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record UsageStatsListJson( + List stats +) {} diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java index 423f2b3af..abf7d691d 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.java @@ -1,12 +1,15 @@ -/** ****************************************************************************** - * Copyright (c) 2026 Eclipse Foundation and others +/****************************************************************************** + * 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 v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. + * 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.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 cc58a81f7..5a11887a7 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -13,6 +13,7 @@ import org.eclipse.openvsx.entities.*; import org.eclipse.openvsx.json.QueryRequest; import org.eclipse.openvsx.json.TargetPlatformVersionJson; +import org.eclipse.openvsx.json.UsageStatsJson; import org.eclipse.openvsx.json.VersionTargetPlatformsJson; import org.eclipse.openvsx.util.ExtensionId; import org.eclipse.openvsx.util.NamingUtil; @@ -22,6 +23,8 @@ import org.springframework.stereotype.Component; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalUnit; import java.util.Collection; import java.util.List; import java.util.Map; @@ -727,6 +730,13 @@ public void deleteCustomer(Customer customer) { customerRepo.delete(customer); } + public List findUsageStatsByCustomerAndDate(Customer customer, LocalDateTime date) { + var startTime = date.truncatedTo(ChronoUnit.DAYS).minusMinutes(5); + var endTime = date.truncatedTo(ChronoUnit.DAYS).plusDays(1); + + return usageStatsRepository.findUsageStatsByCustomerAndWindowStartBetween(customer, startTime, endTime); + } + public UsageStats saveUsageStats(UsageStats usageStats) { return usageStatsRepository.save(usageStats); } diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/TierRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/TierRepository.java index 690dc5205..059410878 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/TierRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/TierRepository.java @@ -1,12 +1,15 @@ -/** ****************************************************************************** - * Copyright (c) 2026 Eclipse Foundation and others +/****************************************************************************** + * 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 v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. + * 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.Tier; diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/UsageStatsRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/UsageStatsRepository.java index 6d93483da..719975363 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/UsageStatsRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/UsageStatsRepository.java @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,12 +9,23 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ + package org.eclipse.openvsx.repositories; +import org.eclipse.openvsx.entities.Customer; import org.eclipse.openvsx.entities.UsageStats; import org.springframework.data.repository.Repository; +import java.time.LocalDateTime; +import java.util.List; + public interface UsageStatsRepository extends Repository { + List findUsageStatsByCustomerAndWindowStartBetween( + Customer customer, + LocalDateTime windowStartAfter, + LocalDateTime windowStartBefore + ); + UsageStats save(UsageStats usageStats); } diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index 21ea3689e..e228e5fe0 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -494,7 +494,7 @@ export interface AdminService { createCustomer(abortController: AbortController, customer: Customer): Promise>; updateCustomer(abortController: AbortController, name: string, customer: Customer): Promise>; deleteCustomer(abortController: AbortController, name: string): Promise>; - getUsageStats(abortController: AbortController, customerName: string, startDate?: Date, endDate?: Date): Promise>; + getUsageStats(abortController: AbortController, customerName: string, date: Date): Promise>; } export type AdminServiceConstructor = new (registry: ExtensionRegistryService) => AdminService; @@ -747,46 +747,20 @@ export class AdminServiceImpl implements AdminService { /** * Get usage stats for a customer within an optional date range. - * Currently returns mocked data. - * TODO: Replace with real backend call when endpoint is available. */ async getUsageStats( abortController: AbortController, customerName: string, - startDate?: Date, - endDate?: Date + date: Date, ): Promise> { - // Simulate network delay - await new Promise(resolve => setTimeout(resolve, 300)); - - // Generate mocked usage stats data - const now = new Date(); - const usageStats = []; - - // Generate 90 days of mocked data (daily stats) - for (let i = 89; i >= 0; i--) { - const windowStart = new Date(now); - windowStart.setDate(windowStart.getDate() - i); - windowStart.setHours(0, 0, 0, 0); - - // Filter by date range if provided - if (startDate && windowStart < startDate) continue; - if (endDate) { - const endOfDay = new Date(endDate); - endOfDay.setHours(23, 59, 59, 999); - if (windowStart > endOfDay) continue; - } - - usageStats.push({ - id: 90 - i, - customerId: customerName.length, // Mock customer ID based on name - windowStart: windowStart.toISOString(), - duration: 86400, // 24 hours in seconds (daily stats) - count: Math.floor(Math.random() * 1000) + 100 // Random count between 100-1100 - }); - } + const query: { key: string, value: string | number }[] = []; + query.push({ key: 'date', value: date.toISOString() }); - return { usageStats }; + return sendRequest({ + abortController, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'customers', customerName, 'usage'], query), + credentials: true + }, false); } } diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index fe27c8613..fa22a3344 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -300,15 +300,11 @@ export interface CustomerList { } export interface UsageStats { - id: number; - customerId: number; windowStart: string; // ISO timestamp duration: number; // in seconds count: number; } export interface UsageStatsList { - usageStats: UsageStats[]; + stats: UsageStats[]; } - -export type UsageStatsPeriod = 'daily' | 'weekly' | 'monthly'; 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 2afe44675..ccd6006f8 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 @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,19 +9,14 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ -import React, { FC, useMemo } from "react"; +import React, {FC, useMemo} from "react"; import { Box, Paper, Typography, Alert, - FormControl, - InputLabel, - Select, - MenuItem, - SelectChangeEvent, useTheme, Stack } from "@mui/material"; @@ -29,43 +24,27 @@ import { BarChart } 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 type { UsageStats, UsageStatsPeriod } from "../../../extension-registry-types"; -import { aggregateByPeriod } from "./usage-stats-utils"; +import type { UsageStats } from "../../../extension-registry-types"; +import {format} from "date-fns"; interface UsageStatsChartProps { usageStats: readonly UsageStats[]; - period: UsageStatsPeriod; - startDate: Date | null; - endDate: Date | null; - onPeriodChange: (event: SelectChangeEvent) => void; - onStartDateChange: (date: Date | null) => void; - onEndDateChange: (date: Date | null) => void; + startDate: Date; + onStartDateChange: (date: Date) => void; } export const UsageStatsChart: FC = ({ usageStats, - period, startDate, - endDate, - onPeriodChange, - onStartDateChange, - onEndDateChange + onStartDateChange }) => { const theme = useTheme(); - const aggregatedData = useMemo( - () => aggregateByPeriod([...usageStats], period), - [usageStats, period] - ); - const totalRequests = useMemo( - () => aggregatedData.reduce((sum, d) => sum + d.count, 0), - [aggregatedData] + () => usageStats.reduce((sum, d) => sum + d.count, 0), + [usageStats] ); - if (usageStats.length === 0) { - return No usage data available for this customer.; - } return ( @@ -79,56 +58,41 @@ export const UsageStatsChart: FC = ({ value={startDate} onChange={onStartDateChange} slotProps={{ textField: { size: 'small' } }} - maxDate={endDate || undefined} - /> - - - Aggregation - -
- - d.period), - }]} - series={[{ - data: aggregatedData.map(d => d.count), - label: 'Request Count', - color: theme.palette.primary.main - }]} - height={400} - slotProps={{ - legend: { position: { vertical: 'top', horizontal: 'right' } } - }} - /> - + {usageStats.length === 0 ? + No usage data available for this customer. + : + <> + + d.windowStart), + valueFormatter: (v) => format(new Date(v), 'hh:mm') + }]} + series={[{ + data: usageStats.map(d => d.count), + label: 'Request Count', + color: theme.palette.primary.main + }]} + height={400} + slotProps={{ + legend: { position: { vertical: 'top', horizontal: 'right' } } + }} + /> + - - - Total requests in selected range: {totalRequests.toLocaleString()} - {usageStats.length > 0 && <> ({usageStats.length} data points)} - - + + + Total requests in selected range: {totalRequests.toLocaleString()} + {usageStats.length > 0 && <> ({usageStats.length} data points)} + + + + } ); }; \ No newline at end of file diff --git a/webui/src/pages/admin-dashboard/usage-stats/usage-stats-search.tsx b/webui/src/pages/admin-dashboard/usage-stats/usage-stats-search.tsx index da329872a..61563b13f 100644 --- a/webui/src/pages/admin-dashboard/usage-stats/usage-stats-search.tsx +++ b/webui/src/pages/admin-dashboard/usage-stats/usage-stats-search.tsx @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,7 +9,7 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ import React, { FC } from "react"; import { Paper, Autocomplete, InputBase, IconButton } from "@mui/material"; 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 cc83ce4cd..c36de3302 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 @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,48 +9,8 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ - -import { format, startOfWeek, startOfMonth } from 'date-fns'; -import type { UsageStats, UsageStatsPeriod } from "../../../extension-registry-types"; - -/** - * Maps each {@link UsageStatsPeriod} to a function that derives an aggregation key - * string from a given {@link Date}. The resulting keys are used to group usage - * statistics into daily, weekly, or monthly buckets. - * - * Each extractor must be deterministic for a given input date and return a - * human-readable string that can be used as a grouping key. - */ -export const periodKeyExtractors: Record string> = { - daily: (date) => format(date, 'yyyy-MM-dd'), - weekly: (date) => `Week of ${format(startOfWeek(date, { weekStartsOn: 1 }), 'yyyy-MM-dd')}`, - monthly: (date) => format(startOfMonth(date), 'yyyy-MM') -}; - -/** - * Aggregates usage statistics by a specified period, summing counts for each period key. - * - * @param stats - An array of UsageStats objects to aggregate. - * @param period - The period type (e.g., daily, weekly) used to extract keys from dates. - * @returns An array of objects, each containing a 'period' string key and the aggregated 'count' number, sorted by period. - */ -export const aggregateByPeriod = (stats: UsageStats[], period: UsageStatsPeriod) => { - const getKey = periodKeyExtractors[period]; - const aggregated = new Map(); - - for (const stat of stats) { - const key = getKey(new Date(stat.windowStart)); - aggregated.set(key, (aggregated.get(key) || 0) + stat.count); - } - - return Array.from(aggregated.entries()) - .sort((a, b) => a[0].localeCompare(b[0])) - .map(([period, count]) => ({ period, count })); -}; + *****************************************************************************/ export const getDefaultStartDate = () => { - const date = new Date(); - date.setDate(date.getDate() - 30); - return date; + return new Date(); }; \ No newline at end of file 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 3b244f16b..6a20c4fc6 100644 --- a/webui/src/pages/admin-dashboard/usage-stats/usage-stats.tsx +++ b/webui/src/pages/admin-dashboard/usage-stats/usage-stats.tsx @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,13 +9,13 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ import React, { FC, useState, useEffect, useRef, useMemo, useCallback } from "react"; -import { Box, Alert, SelectChangeEvent } from "@mui/material"; +import { Box, Alert } from "@mui/material"; import { useParams, useNavigate } from "react-router-dom"; import { MainContext } from "../../../context"; -import type { UsageStats, UsageStatsPeriod, Customer } from "../../../extension-registry-types"; +import type { UsageStats, Customer } from "../../../extension-registry-types"; import { handleError } from "../../../utils"; import { AdminDashboardRoutes } from "../admin-dashboard"; import { SearchListContainer } from "../search-list-container"; @@ -34,9 +34,7 @@ export const UsageStatsView: FC = () => { const [usageStats, setUsageStats] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [period, setPeriod] = useState('daily'); - const [startDate, setStartDate] = useState(getDefaultStartDate); - const [endDate, setEndDate] = useState(new Date()); + const [startDate, setStartDate] = useState(getDefaultStartDate); // Load customers for autocomplete useEffect(() => { @@ -81,16 +79,15 @@ export const UsageStatsView: FC = () => { const data = await service.admin.getUsageStats( abortController.current, customer, - startDate || undefined, - endDate || undefined + startDate ); - setUsageStats(data.usageStats); + setUsageStats(data.stats); } catch (err) { setError(handleError(err as Error)); } finally { setLoading(false); } - }, [service, customer, startDate, endDate]); + }, [service, customer, startDate]); useEffect(() => { if (customer) { @@ -98,10 +95,6 @@ export const UsageStatsView: FC = () => { } }, [loadUsageStats, customer]); - const handlePeriodChange = (event: SelectChangeEvent) => { - setPeriod(event.target.value as UsageStatsPeriod); - }; - if (error) { return {error}; } @@ -125,12 +118,8 @@ export const UsageStatsView: FC = () => { {customer && ( )}
); From f9f04b71f215684972224d7887b360931f7f8897 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Mon, 2 Feb 2026 18:30:45 +0100 Subject: [PATCH 40/45] use qualifier for fileCacheManager --- .../src/main/java/org/eclipse/openvsx/cache/CacheConfig.java | 3 ++- .../src/main/java/org/eclipse/openvsx/cache/CacheService.java | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/org/eclipse/openvsx/cache/CacheConfig.java b/server/src/main/java/org/eclipse/openvsx/cache/CacheConfig.java index 4f39a00f6..e89b61d7f 100644 --- a/server/src/main/java/org/eclipse/openvsx/cache/CacheConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/cache/CacheConfig.java @@ -25,6 +25,7 @@ import org.eclipse.openvsx.search.SearchResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -101,7 +102,7 @@ public Cache browseCache( } @Bean - public CacheManager fileCacheManager( + public @Qualifier("fileCacheManager") CacheManager fileCacheManager( Cache extensionCache, Cache webResourceCache, Cache browseCache diff --git a/server/src/main/java/org/eclipse/openvsx/cache/CacheService.java b/server/src/main/java/org/eclipse/openvsx/cache/CacheService.java index 06b805f62..eceeefab1 100644 --- a/server/src/main/java/org/eclipse/openvsx/cache/CacheService.java +++ b/server/src/main/java/org/eclipse/openvsx/cache/CacheService.java @@ -47,7 +47,7 @@ public class CacheService { private final FilesCacheKeyGenerator filesCacheKeyGenerator; public CacheService( - CacheManager fileCacheManager, + @Qualifier("fileCacheManager") CacheManager fileCacheManager, RepositoryService repositories, ExtensionJsonCacheKeyGenerator extensionJsonCacheKey, LatestExtensionVersionCacheKeyGenerator latestExtensionVersionCacheKey, From fd14b8e3cf761424d67a0cc95aaac34e1cb09d6a Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Mon, 2 Feb 2026 18:30:58 +0100 Subject: [PATCH 41/45] fix copyright header --- .../eclipse/openvsx/entities/DurationSecondsConverter.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/org/eclipse/openvsx/entities/DurationSecondsConverter.java b/server/src/main/java/org/eclipse/openvsx/entities/DurationSecondsConverter.java index 903a4a983..db319a4f6 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/DurationSecondsConverter.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/DurationSecondsConverter.java @@ -1,4 +1,4 @@ -/* +/****************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation. * * See the NOTICE file(s) distributed with this work for additional @@ -9,7 +9,7 @@ * https://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 - */ + *****************************************************************************/ package org.eclipse.openvsx.entities; import jakarta.persistence.AttributeConverter; From 8a380e0d95d8b26d5df1d882456dc94f4c237b76 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Mon, 2 Feb 2026 18:31:10 +0100 Subject: [PATCH 42/45] update admin dashboard welcome --- webui/src/pages/admin-dashboard/welcome.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/webui/src/pages/admin-dashboard/welcome.tsx b/webui/src/pages/admin-dashboard/welcome.tsx index e8e0abd62..f95d3b82c 100644 --- a/webui/src/pages/admin-dashboard/welcome.tsx +++ b/webui/src/pages/admin-dashboard/welcome.tsx @@ -29,6 +29,7 @@ export const Welcome: FunctionComponent = props => { + From 470608950af69ef94d2bc385e0683159c0d29df6 Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Wed, 4 Feb 2026 09:37:28 +0100 Subject: [PATCH 43/45] fix error in debug mode --- webui/src/components/sanitized-markdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webui/src/components/sanitized-markdown.tsx b/webui/src/components/sanitized-markdown.tsx index 7d2e07de3..ffce04338 100644 --- a/webui/src/components/sanitized-markdown.tsx +++ b/webui/src/components/sanitized-markdown.tsx @@ -94,7 +94,7 @@ const Markdown = styled('div')(({ theme }: { theme: Theme }) => ({ } }, '& .markdown-alert-title:before': { - content: " ", + content: '" "', width: '16px', height: '16px', marginRight: '8px', From 29d46e5dd5dcf7d1b44f45e845ee023e50ab074d Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Wed, 4 Feb 2026 15:48:47 +0100 Subject: [PATCH 44/45] improve usage stats chart --- .../usage-stats/usage-stats-chart.tsx | 108 +++++++++++++++--- 1 file changed, 91 insertions(+), 17 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 ccd6006f8..f1b8d7a66 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,15 +17,21 @@ import { Paper, Typography, Alert, - useTheme, Stack } from "@mui/material"; -import { BarChart } from "@mui/x-charts/BarChart"; +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 type { UsageStats } from "../../../extension-registry-types"; -import {format} from "date-fns"; +import {addDays, format, startOfDay} from "date-fns"; +import { + ChartsReferenceLine, + ChartsTooltip, + ChartsXAxis, + ChartsYAxis, + ResponsiveChartContainer +} from "@mui/x-charts"; interface UsageStatsChartProps { usageStats: readonly UsageStats[]; @@ -38,14 +44,38 @@ export const UsageStatsChart: FC = ({ startDate, onStartDateChange }) => { - const theme = useTheme(); + const dayStart = startOfDay(startDate).getTime(); + const dayEnd = startOfDay(addDays(startDate, 1)).getTime(); + + const data: UsageStats[] = useMemo( + () => { + let arr: UsageStats[] = []; + + // we have 5min steps + const step = 5 * 60 * 1000; + + for (let idx = dayStart; idx < dayEnd; idx += step) { + arr.push({ + windowStart: new Date(idx).toISOString(), + duration: step, + count: 0 + }) + } + + for (const stat of usageStats) { + const w = new Date(stat.windowStart).getTime(); + const idx = (w - dayStart) / step; + arr[idx].count = stat.count; + } + return arr; + }, [usageStats] + ); const totalRequests = useMemo( () => usageStats.reduce((sum, d) => sum + d.count, 0), [usageStats] ); - return ( @@ -67,22 +97,66 @@ export const UsageStatsChart: FC = ({ : <> - d.windowStart), - valueFormatter: (v) => format(new Date(v), 'hh:mm') - }]} + d.count), + type: 'bar', + data: data.map(d => d.count), label: 'Request Count', - color: theme.palette.primary.main + color: 'lightgray', }]} + height={400} - slotProps={{ - legend: { position: { vertical: 'top', horizontal: 'right' } } - }} - /> + margin={{ top: 10 }} + xAxis={[ + { + id: 'date', + data: data.map((value) => new Date(value.windowStart)), + scaleType: 'band', + valueFormatter: (value) => format(new Date(value), 'HH:mm'), + }, + ]} + yAxis={[ + { + id: 'requests', + scaleType: 'linear', + }, + ]} + > + + + + + { + return new Date(value).getMinutes() === 0; + }} + tickLabelInterval={(value, index) => { + return new Date(value).getMinutes() === 0; + }} + tickLabelStyle={{ + fontSize: 10, + }} + /> + + + From e9d1d59e7d9e316b81bd2e18044af3d764dc81bd Mon Sep 17 00:00:00 2001 From: Thomas Neidhart Date: Wed, 4 Feb 2026 16:31:28 +0100 Subject: [PATCH 45/45] chart updates --- .../eclipse/openvsx/admin/RateLimitAPI.java | 2 +- .../eclipse/openvsx/entities/UsageStats.java | 3 +- .../eclipse/openvsx/json/UsageStatsJson.java | 2 +- webui/src/extension-registry-types.ts | 2 +- .../usage-stats/usage-stats-chart.tsx | 57 ++++++++++++------- .../usage-stats/usage-stats.tsx | 1 + 6 files changed, 43 insertions(+), 24 deletions(-) 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 e66ddb1d7..b05b6444b 100644 --- a/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java @@ -320,7 +320,7 @@ public ResponseEntity deleteCustomer(@PathVariable String name) { path = "/customers/{name}/usage", produces = MediaType.APPLICATION_JSON_VALUE ) - public ResponseEntity getCustomers(@PathVariable String name, @RequestParam(required = false) String date) { + public ResponseEntity getUsageStats(@PathVariable String name, @RequestParam(required = false) String date) { try { admins.checkAdminUser(); diff --git a/server/src/main/java/org/eclipse/openvsx/entities/UsageStats.java b/server/src/main/java/org/eclipse/openvsx/entities/UsageStats.java index d3e850c39..9b9859623 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/UsageStats.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/UsageStats.java @@ -20,6 +20,7 @@ import java.io.Serializable; import java.time.Duration; import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.Objects; @Entity @@ -90,7 +91,7 @@ public void setCount(long count) { } public UsageStatsJson toJson() { - return new UsageStatsJson(windowStart.toString(), duration.toSeconds(), count); + return new UsageStatsJson(windowStart.toEpochSecond(ZoneOffset.UTC), duration.toSeconds(), count); } @Override diff --git a/server/src/main/java/org/eclipse/openvsx/json/UsageStatsJson.java b/server/src/main/java/org/eclipse/openvsx/json/UsageStatsJson.java index f035143c4..0fb53562c 100644 --- a/server/src/main/java/org/eclipse/openvsx/json/UsageStatsJson.java +++ b/server/src/main/java/org/eclipse/openvsx/json/UsageStatsJson.java @@ -15,4 +15,4 @@ import com.fasterxml.jackson.annotation.JsonInclude; @JsonInclude(JsonInclude.Include.NON_NULL) -public record UsageStatsJson(String windowStart, long duration, long count) {} \ No newline at end of file +public record UsageStatsJson(long windowStart, long duration, long count) {} \ No newline at end of file diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index fa22a3344..60799da76 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -300,7 +300,7 @@ export interface CustomerList { } export interface UsageStats { - windowStart: string; // ISO timestamp + windowStart: number; // epoch seconds in UTC duration: number; // in seconds count: number; } 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 f1b8d7a66..3c74ce375 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 @@ -23,7 +23,7 @@ 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 type { UsageStats } from "../../../extension-registry-types"; +import type {Customer, UsageStats } from "../../../extension-registry-types"; import {addDays, format, startOfDay} from "date-fns"; import { ChartsReferenceLine, @@ -35,42 +35,55 @@ import { interface UsageStatsChartProps { usageStats: readonly UsageStats[]; + customer: Customer | null; startDate: Date; onStartDateChange: (date: Date) => void; } export const UsageStatsChart: FC = ({ usageStats, + customer, startDate, onStartDateChange }) => { - const dayStart = startOfDay(startDate).getTime(); - const dayEnd = startOfDay(addDays(startDate, 1)).getTime(); + const dayStart = startOfDay(startDate).getTime() / 1000; + const dayEnd = startOfDay(addDays(startDate, 1)).getTime() / 1000; + + // we have 5min steps + const step = 5 * 60; + const tierCapacity = + customer?.tier !== undefined ? customer.tier.capacity * step / customer.tier.duration : 0; const data: UsageStats[] = useMemo( () => { let arr: UsageStats[] = []; - // we have 5min steps - const step = 5 * 60 * 1000; - for (let idx = dayStart; idx < dayEnd; idx += step) { arr.push({ - windowStart: new Date(idx).toISOString(), + windowStart: idx, duration: step, count: 0 }) } for (const stat of usageStats) { - const w = new Date(stat.windowStart).getTime(); - const idx = (w - dayStart) / step; + const idx = (stat.windowStart - dayStart) / step; arr[idx].count = stat.count; } return arr; }, [usageStats] ); + const maxDataValue: number = useMemo( + () => { + if (usageStats.length === 0) { + return 0; + } else { + return Math.max(...usageStats.map(v => v.count)); + } + }, [usageStats] + ) + const totalRequests = useMemo( () => usageStats.reduce((sum, d) => sum + d.count, 0), [usageStats] @@ -110,7 +123,7 @@ export const UsageStatsChart: FC = ({ xAxis={[ { id: 'date', - data: data.map((value) => new Date(value.windowStart)), + data: data.map((value) => new Date(value.windowStart * 1000)), scaleType: 'band', valueFormatter: (value) => format(new Date(value), 'HH:mm'), }, @@ -119,21 +132,25 @@ export const UsageStatsChart: FC = ({ { id: 'requests', scaleType: 'linear', + min: 0, + max: Math.max(tierCapacity, maxDataValue) + 10 }, ]} > - + {tierCapacity > 0 && + + } { {customer && ( )}