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 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 diff --git a/server/build.gradle b/server/build.gradle index e2292eb32..104fc7c08 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', @@ -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 new file mode 100644 index 000000000..e134b483f --- /dev/null +++ b/server/src/dev/resources/db/migration/V1_58_1__RateLimit.sql @@ -0,0 +1,6 @@ +-- add basic tier and assign customer for loopback IP to it + +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 new file mode 100644 index 000000000..b05b6444b --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/admin/RateLimitAPI.java @@ -0,0 +1,341 @@ +/****************************************************************************** + * 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.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.cache.RateLimitCacheService; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.util.TimeUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.Optional; + + +@RestController +@RequestMapping("/admin/ratelimit") +public class RateLimitAPI { + private final Logger logger = LoggerFactory.getLogger(RateLimitAPI.class); + + private final RepositoryService repositories; + private final AdminService admins; + private RateLimitCacheService rateLimitCacheService; + + public RateLimitAPI( + RepositoryService repositories, + AdminService admins, + Optional rateLimitCacheService + ) { + this.repositories = repositories; + this.admins = admins; + rateLimitCacheService.ifPresent(service -> this.rateLimitCacheService = service); + } + + @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 { + var adminUser = admins.checkAdminUser(); + + var existingTier = repositories.findTier(tier.getName()); + if (existingTier != null) { + 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 = savedTier.toJson(); + result.setSuccess("Created tier '" + savedTier.getName() + "'"); + + admins.logAdminAction(adminUser, result); + + return ResponseEntity.ok(result); + } catch (Exception exc) { + logger.error("failed creating tier {}", tier.getName(), 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 { + var adminUser = admins.checkAdminUser(); + + var savedTier = repositories.findTier(name); + if (savedTier == null) { + 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 = savedTier.toJson(); + result.setSuccess("Updated tier '" + savedTier.getName() + "'"); + admins.logAdminAction(adminUser, result); + + if (rateLimitCacheService != null) { + rateLimitCacheService.evictConfigurationCache(); + } + + return ResponseEntity.ok(result); + } 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 { + var adminUser = admins.checkAdminUser(); + + var tier = repositories.findTier(name); + if (tier == null) { + return ResponseEntity.notFound().build(); + } + + 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); + + 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 = "/tiers/{name}/customers", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity getCustomersForTier(@PathVariable String name) { + try { + admins.checkAdminUser(); + + var tier = repositories.findTier(name); + if (tier == null) { + return ResponseEntity.notFound().build(); + } + + var existingCustomers = repositories.findCustomersByTier(tier); + var result = new CustomerListJson(existingCustomers.stream().map(Customer::toJson).toList()); + return ResponseEntity.ok(result); + } catch (Exception exc) { + logger.error("failed getting customers for 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.getName()); + if (existingCustomer != null) { + 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() + .body(CustomerJson.error("Tier with name " + customer.getTier().getName() + " does not exist")); + } + customer.setTier(tier); + + var savedCustomer = repositories.upsertCustomer(customer); + + var result = savedCustomer.toJson(); + result.setSuccess("Created customer '" + savedCustomer.getName() + "'"); + admins.logAdminAction(adminUser, result); + + return ResponseEntity.ok(result); + } catch (Exception exc) { + logger.error("failed creating customer {}", customerJson.getName(), 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() + .body(CustomerJson.error("Tier with name " + customer.getTier().getName() + " does not exist")); + } + savedCustomer.setTier(tier); + + savedCustomer = repositories.upsertCustomer(savedCustomer); + + var result = savedCustomer.toJson(); + 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); + 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(); + } + } + + @GetMapping( + path = "/customers/{name}/usage", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity getUsageStats(@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/cache/CacheConfig.java b/server/src/main/java/org/eclipse/openvsx/cache/CacheConfig.java index 777d0527a..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 @@ -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..eceeefab1 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, + @Qualifier("fileCacheManager") 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 new file mode 100644 index 000000000..f26819722 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/entities/Customer.java @@ -0,0 +1,140 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ +package org.eclipse.openvsx.entities; + +import jakarta.persistence.*; +import org.eclipse.openvsx.json.CustomerJson; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +@Entity +public class Customer implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(generator = "customerSeq") + @SequenceGenerator(name = "customerSeq", sequenceName = "customer_seq") + private long id; + + @Column(nullable = false, unique = true) + private String name; + + @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(); + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Tier getTier() { + return tier; + } + + 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 setCidrBlocks(List cidrBlocks) { + this.cidrBlocks = cidrBlocks; + } + + public CustomerJson toJson() { + 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.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.getName()); + customer.setTier(Tier.fromJson(json.getTier())); + customer.setState(EnforcementState.valueOf(json.getState())); + customer.setCidrBlocks(json.getCidrBlocks()); + return customer; + } + + @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(tier, that.tier) + && Objects.equals(cidrBlocks, that.cidrBlocks); + } + + @Override + public int hashCode() { + return Objects.hash(id, name, tier, cidrBlocks); + } + + @Override + public String toString() { + return "Customer{" + + "name='" + name + '\'' + + ", tier=" + tier + + ", state=" + state + + ", cidrBlocks=" + cidrBlocks + + '}'; + } +} 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..db319a4f6 --- /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/EnforcementState.java b/server/src/main/java/org/eclipse/openvsx/entities/EnforcementState.java new file mode 100644 index 000000000..ed97cda99 --- /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/java/org/eclipse/openvsx/entities/RefillStrategy.java b/server/src/main/java/org/eclipse/openvsx/entities/RefillStrategy.java new file mode 100644 index 000000000..b685a97fc --- /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..86b2bfa53 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/entities/Tier.java @@ -0,0 +1,168 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ +package org.eclipse.openvsx.entities; + +import jakarta.persistence.*; +import org.eclipse.openvsx.json.TierJson; + +import java.io.Serial; +import java.io.Serializable; +import java.time.Duration; +import java.util.Objects; + +@Entity +public class Tier implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(generator = "tierSeq") + @SequenceGenerator(name = "tierSeq", sequenceName = "tier_seq") + private long id; + + @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; + + @Column(nullable = false) + @Convert(converter = DurationSecondsConverter.class) + private Duration duration = Duration.ofMinutes(5); + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private RefillStrategy refillStrategy = RefillStrategy.GREEDY; + + public long getId() { + return id; + } + + 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 TierType getTierType() { + return tierType; + } + + public void setTierType(TierType tierType) { + this.tierType = tierType; + } + + public int getCapacity() { + return capacity; + } + + public void setCapacity(int capacity) { + this.capacity = capacity; + } + + public Duration getDuration() { + return duration; + } + + public void setDuration(Duration duration) { + this.duration = duration; + } + + public RefillStrategy getRefillStrategy() { + return refillStrategy; + } + + public void setRefillStrategy(RefillStrategy refillStrategy) { + this.refillStrategy = refillStrategy; + } + + public TierJson toJson() { + 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.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.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; + } + + @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(tierType, that.tierType) + && Objects.equals(capacity, that.capacity) + && Objects.equals(duration, that.duration) + && Objects.equals(refillStrategy, that.refillStrategy); + } + + @Override + public int hashCode() { + return Objects.hash(id, name, description, tierType, capacity, duration, refillStrategy); + } + + @Override + public String toString() { + return "Tier{" + + "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 new file mode 100644 index 000000000..9b9859623 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/entities/UsageStats.java @@ -0,0 +1,123 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ +package org.eclipse.openvsx.entities; + +import jakarta.persistence.*; +import org.eclipse.openvsx.json.CustomerJson; +import org.eclipse.openvsx.json.UsageStatsJson; + +import java.io.Serial; +import java.io.Serializable; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +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 LocalDateTime windowStart; + + @Column(nullable = false) + @Convert(converter = DurationSecondsConverter.class) + private Duration duration; + + @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; + } + + public UsageStatsJson toJson() { + return new UsageStatsJson(windowStart.toEpochSecond(ZoneOffset.UTC), duration.toSeconds(), count); + } + + @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/json/CustomerJson.java b/server/src/main/java/org/eclipse/openvsx/json/CustomerJson.java new file mode 100644 index 000000000..52f3e605b --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/CustomerJson.java @@ -0,0 +1,69 @@ +/****************************************************************************** + * 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 jakarta.validation.constraints.NotNull; + +import java.util.List; + +@JsonInclude(JsonInclude.Include.NON_NULL) +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 new file mode 100644 index 000000000..a20620fb3 --- /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/json/TierJson.java b/server/src/main/java/org/eclipse/openvsx/json/TierJson.java new file mode 100644 index 000000000..39063b0d6 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/TierJson.java @@ -0,0 +1,88 @@ +/****************************************************************************** + * 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 jakarta.validation.constraints.NotNull; + +@JsonInclude(JsonInclude.Include.NON_NULL) +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/json/TierListJson.java b/server/src/main/java/org/eclipse/openvsx/json/TierListJson.java new file mode 100644 index 000000000..ba3d958ae --- /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/json/UsageStatsJson.java b/server/src/main/java/org/eclipse/openvsx/json/UsageStatsJson.java new file mode 100644 index 000000000..0fb53562c --- /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(long 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/ratelimit/CustomerService.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerService.java new file mode 100644 index 000000000..260cd9b07 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/CustomerService.java @@ -0,0 +1,77 @@ +/****************************************************************************** + * 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 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.Service; + +import java.util.Optional; + + +@Service +@ConditionalOnBean(RateLimitConfig.class) +public class CustomerService { + + private final Logger logger = LoggerFactory.getLogger(CustomerService.class); + + private final RepositoryService repositories; + + public CustomerService(RepositoryService repositories) { + this.repositories = repositories; + } + + @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 = RateLimitCacheService.CACHE_CUSTOMER, cacheManager = RateLimitCacheService.CACHE_MANAGER) + 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/IdentityService.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/IdentityService.java new file mode 100644 index 000000000..8aa2d39c9 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/IdentityService.java @@ -0,0 +1,69 @@ +/****************************************************************************** + * 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.eclipse.openvsx.ratelimit.config.RateLimitConfig; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.stereotype.Service; + +@Service +@ConditionalOnBean(RateLimitConfig.class) +public class IdentityService { + + private final CustomerService customerService; + + public IdentityService(CustomerService customerService) { + this.customerService = customerService; + } + + public ResolvedIdentity resolveIdentity(HttpServletRequest request) { + 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(); + } + + 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(); + } + } + + if (cacheKey == null) { + cacheKey = "ip_" + ipAddress; + } + + return new ResolvedIdentity(cacheKey, customer.orElse(null), customerService.getFreeTier().orElse(null), null); + } + + 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..4c9974fd6 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/RateLimitService.java @@ -0,0 +1,87 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ +package org.eclipse.openvsx.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.cache.RateLimitCacheService; +import org.eclipse.openvsx.ratelimit.config.RateLimitConfig; +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.Service; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; + + +@Service +@ConditionalOnBean(RateLimitConfig.class) +public class RateLimitService { + private final Logger logger = LoggerFactory.getLogger(RateLimitService.class); + + private final ProxyManager proxyManager; + + public RateLimitService(ProxyManager proxyManager) { + this.proxyManager = proxyManager; + } + + @Cacheable(value = RateLimitCacheService.CACHE_BUCKET, cacheManager = RateLimitCacheService.CACHE_MANAGER) + 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(); + + logger.info("getting bandwidth for customer {} - {}", identity.getCustomer().getName(), tier.getCapacity()); + + 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 { + var freeTier = identity.freeTier(); + + var fillStage = Bandwidth.builder().capacity(freeTier.getCapacity()); + + 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(); + + // 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 new file mode 100644 index 000000000..ded955b00 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/ResolvedIdentity.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; + +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 Tier freeTier, + @Nullable Tier safetyTier + ) { + + public boolean isCustomer() { + return customer != null; + } + + public @NotNull Customer getCustomer() { + if (isCustomer()) { + return customer; + } else { + throw new RuntimeException("no customer associated with identity"); + } + } +} 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..5bfe907e4 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/UsageDataService.java @@ -0,0 +1,107 @@ +/****************************************************************************** + * 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.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.stereotype.Component; +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; + +@Component +@ConditionalOnBean(RateLimitConfig.class) +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"; + 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.debug("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.debug("usage stats: {} - {}", 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/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/RateLimitAutoConfiguration.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/RateLimitAutoConfiguration.java new file mode 100644 index 000000000..d6f542f2e --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/RateLimitAutoConfiguration.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.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.support.GenericApplicationContext; +import org.springframework.util.StringUtils; + +import java.util.concurrent.atomic.AtomicInteger; + +@Configuration +@ConditionalOnBean(RateLimitConfig.class) +public class RateLimitAutoConfiguration implements WebServerFactoryCustomizer { + + private final Logger logger = LoggerFactory.getLogger(RateLimitAutoConfiguration.class); + + private final GenericApplicationContext context; + private final RateLimitProperties properties; + private final RateLimitServletFilterFactory filterFactory; + + public RateLimitAutoConfiguration( + GenericApplicationContext context, + RateLimitProperties properties, + RateLimitServletFilterFactory 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, + RateLimitServletFilter.class, + () -> filterFactory.create(filter)); + + logger.info("rate-limit:create-servlet-filter;{};{}", filterCount, filter.getUrl()); + }); + } + + private void setDefaults(RateLimitFilterProperties 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/RateLimitConfig.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/RateLimitConfig.java new file mode 100644 index 000000000..f8cfd7dc4 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/RateLimitConfig.java @@ -0,0 +1,151 @@ +/****************************************************************************** + * 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.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 io.micrometer.common.util.StringUtils; +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.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; + +import static org.eclipse.openvsx.ratelimit.cache.RateLimitCacheService.*; + +@Configuration +@ConditionalOnProperty(prefix = RateLimitProperties.PROPERTY_PREFIX, name = "enabled", havingValue = "true") +@EnableConfigurationProperties({RateLimitProperties.class}) +public class RateLimitConfig { + + private final Logger logger = LoggerFactory.getLogger(RateLimitConfig.class); + + @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 + 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 tierCache( + @Value("${ovsx.caching.tier.tti:PT1H}") Duration timeToIdle, + @Value("${ovsx.caching.tier.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 + 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(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_CUSTOMER, customerCache); + caffeineCacheManager.registerCustomCache(CACHE_TIER, tierCache); + caffeineCacheManager.registerCustomCache(CACHE_TOKEN, tokenCache); + caffeineCacheManager.registerCustomCache(CACHE_BUCKET, bucketCache); + + 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/config/RateLimitFilterProperties.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/RateLimitFilterProperties.java new file mode 100644 index 000000000..38bfff3c4 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/RateLimitFilterProperties.java @@ -0,0 +1,121 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ +package org.eclipse.openvsx.ratelimit.config; + +import java.io.Serial; +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 RateLimitFilterProperties implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 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/RateLimitProperties.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/RateLimitProperties.java new file mode 100644 index 000000000..091e629a6 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/config/RateLimitProperties.java @@ -0,0 +1,89 @@ +/****************************************************************************** + * 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 = RateLimitProperties.PROPERTY_PREFIX) +public class RateLimitProperties { + + public static final String PROPERTY_PREFIX = "ovsx.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; + } +} \ No newline at end of file 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..e398dfff5 --- /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; + +import static org.eclipse.openvsx.ratelimit.UsageDataService.COLLECT_USAGE_STATS_SCHEDULE; + +@Component +public class ScheduleRateLimitJobs { + + 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/filter/RateLimitServletFilter.java b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/RateLimitServletFilter.java new file mode 100644 index 000000000..22adb080d --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/RateLimitServletFilter.java @@ -0,0 +1,104 @@ +/****************************************************************************** + * 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 io.github.bucket4j.ConsumptionProbe; +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.eclipse.openvsx.ratelimit.RateLimitService; +import org.eclipse.openvsx.ratelimit.UsageDataService; +import org.eclipse.openvsx.ratelimit.IdentityService; +import org.eclipse.openvsx.ratelimit.config.RateLimitFilterProperties; +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.util.concurrent.TimeUnit; + +public class RateLimitServletFilter extends OncePerRequestFilter implements Ordered { + + private final Logger logger = LoggerFactory.getLogger(RateLimitServletFilter.class); + + private final RateLimitFilterProperties filterProperties; + private final UsageDataService customerUsageService; + private final IdentityService identityService; + private final RateLimitService rateLimitService; + + public RateLimitServletFilter( + RateLimitFilterProperties filterProperties, + UsageDataService customerUsageService, + IdentityService identityService, + RateLimitService rateLimitService + ) { + this.filterProperties = filterProperties; + this.customerUsageService = customerUsageService; + this.identityService = identityService; + this.rateLimitService = rateLimitService; + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return !request.getRequestURI().matches(filterProperties.getUrl()); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { + logger.debug("rate limit filter: {}: {}", request.getRequestURI(), request.getRemoteAddr()); + + var identity = identityService.resolveIdentity(request); + + if (identity.isCustomer()) { + var customer = identity.getCustomer(); + logger.info("updating usage status for customer {}", customer.getName()); + customerUsageService.incrementUsage(customer); + } + + var bucket = rateLimitService.getBucket(identity); + + // 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 { + handleHttpResponseOnRateLimiting(response, probe); + } + } + + 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() { + return filterProperties.getFilterOrder(); + } +} 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..4748e83e5 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/filter/RateLimitServletFilterFactory.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.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.RateLimitConfig; +import org.eclipse.openvsx.ratelimit.config.RateLimitFilterProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnBean(RateLimitConfig.class) +public class RateLimitServletFilterFactory { + private final UsageDataService usageService; + private final IdentityService identityService; + private final RateLimitService rateLimitService; + + public RateLimitServletFilterFactory( + UsageDataService usageService, + IdentityService identityService, + RateLimitService rateLimitService + ) { + this.usageService = usageService; + this.identityService = identityService; + this.rateLimitService = 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 new file mode 100644 index 000000000..ec75e9ac1 --- /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..beb341a6b --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/ratelimit/jobs/CollectUsageStatsJobRequestHandler.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.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(">> start collecting usage data"); + usageDataService.persistUsageStats(); + logger.info("<< finished collecting usage data"); + } + } +} 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..abf7d691d --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/repositories/CustomerRepository.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.repositories; + +import org.eclipse.openvsx.entities.Customer; +import org.eclipse.openvsx.entities.Tier; +import org.springframework.data.repository.Repository; + +import java.util.List; +import java.util.Optional; + +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 ac2760ee9..5a11887a7 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -9,9 +9,11 @@ ********************************************************************************/ 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; +import org.eclipse.openvsx.json.UsageStatsJson; import org.eclipse.openvsx.json.VersionTargetPlatformsJson; import org.eclipse.openvsx.util.ExtensionId; import org.eclipse.openvsx.util.NamingUtil; @@ -21,9 +23,12 @@ 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; +import java.util.Optional; import static org.eclipse.openvsx.entities.FileResource.*; @@ -60,6 +65,9 @@ public class RepositoryService { private final MigrationItemJooqRepository migrationItemJooqRepo; private final SignatureKeyPairRepository signatureKeyPairRepo; private final SignatureKeyPairJooqRepository signatureKeyPairJooqRepo; + private final TierRepository tierRepo; + private final CustomerRepository customerRepo; + private final UsageStatsRepository usageStatsRepository; public RepositoryService( NamespaceRepository namespaceRepo, @@ -84,7 +92,10 @@ public RepositoryService( MigrationItemRepository migrationItemRepo, MigrationItemJooqRepository migrationItemJooqRepo, SignatureKeyPairRepository signatureKeyPairRepo, - SignatureKeyPairJooqRepository signatureKeyPairJooqRepo + SignatureKeyPairJooqRepository signatureKeyPairJooqRepo, + TierRepository tierRepo, + CustomerRepository customerRepo, + UsageStatsRepository usageStatsRepository ) { this.namespaceRepo = namespaceRepo; this.namespaceJooqRepo = namespaceJooqRepo; @@ -109,6 +120,9 @@ public RepositoryService( this.migrationItemJooqRepo = migrationItemJooqRepo; this.signatureKeyPairRepo = signatureKeyPairRepo; this.signatureKeyPairJooqRepo = signatureKeyPairJooqRepo; + this.tierRepo = tierRepo; + this.customerRepo = customerRepo; + this.usageStatsRepository = usageStatsRepository; } public Namespace findNamespace(String name) { @@ -663,4 +677,67 @@ public List findRemoveFileResourceTypeResourceMigrationItems(int public boolean isDeleteAllVersions(String namespaceName, String extensionName, List targetVersions, UserData user) { return extensionVersionJooqRepo.isDeleteAllVersions(namespaceName, extensionName, targetVersions, user); } -} \ No newline at end of file + + public List findAllTiers() { + 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); + } + + public void deleteTier(Tier tier) { + tierRepo.delete(tier); + } + + public List findAllCustomers() { + return customerRepo.findAll(); + } + + public List findCustomersByTier(Tier tier) { + return customerRepo.findByTier(tier); + } + + public int countCustomersByTier(Tier tier) { + return customerRepo.countCustomersByTier(tier); + } + + public Optional findCustomerById(long id) { + return customerRepo.findById(id); + } + + public Customer findCustomer(String name) { + return customerRepo.findByNameIgnoreCase(name); + } + + public Customer upsertCustomer(Customer customer) { + return customerRepo.save(customer); + } + + 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 new file mode 100644 index 000000000..059410878 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/repositories/TierRepository.java @@ -0,0 +1,33 @@ +/****************************************************************************** + * 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.Tier; +import org.eclipse.openvsx.entities.TierType; +import org.springframework.data.repository.Repository; + +import java.util.List; + +public interface TierRepository extends Repository { + 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/java/org/eclipse/openvsx/repositories/UsageStatsRepository.java b/server/src/main/java/org/eclipse/openvsx/repositories/UsageStatsRepository.java new file mode 100644 index 000000000..719975363 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/repositories/UsageStatsRepository.java @@ -0,0 +1,31 @@ +/****************************************************************************** + * 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.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/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..cc04a386f --- /dev/null +++ b/server/src/main/resources/db/migration/V1_58__Rate_Limit.sql @@ -0,0 +1,60 @@ +-- create tier table +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 +); + +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); + +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, + tier_id bigint, + state CHARACTER VARYING(255) NOT NULL, + cidr_blocks 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 TIMESTAMP WITHOUT TIME ZONE 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); 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 diff --git a/webui/package.json b/webui/package.json index a884debb5..0dc514718 100644 --- a/webui/package.json +++ b/webui/package.json @@ -42,8 +42,13 @@ "@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-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", "dompurify": "^3.0.4", "fetch-retry": "^5.0.6", "lodash": "^4.17.21", @@ -72,6 +77,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/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', diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index c90357ba0..e228e5fe0 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, TierList, Customer, CustomerList, UsageStatsList, } from './extension-registry-types'; import { createAbsoluteURL, addQuery } from './utils'; import { sendRequest, ErrorResponse } from './server-request'; @@ -486,11 +486,18 @@ 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> + getTiers(abortController: AbortController): Promise>; + createTier(abortController: AbortController, tier: Tier): Promise>; + updateTier(abortController: AbortController, name: string, tier: Tier): Promise>; + deleteTier(abortController: AbortController, 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>; + getUsageStats(abortController: AbortController, customerName: string, date: Date): Promise>; } -export interface AdminServiceConstructor { - new (registry: ExtensionRegistryService): AdminService -} +export type AdminServiceConstructor = new (registry: ExtensionRegistryService) => AdminService; export class AdminServiceImpl implements AdminService { @@ -609,6 +616,152 @@ export class AdminServiceImpl implements AdminService { headers }); } + + async getTiers(abortController: AbortController): Promise> { + return sendRequest({ + abortController, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'tiers']), + credentials: true + }, false); + } + + 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 sendRequest({ + abortController, + method: 'POST', + payload: tier, + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'tiers', 'create']), + headers + }, false); + } + + 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' + }; + if (!isError(csrfResponse)) { + const csrfToken = csrfResponse as CsrfTokenJson; + headers[csrfToken.header] = csrfToken.value; + } + return sendRequest({ + abortController, + method: 'PUT', + payload: tier, + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'tiers', name]), + headers + }, false); + } + + 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; + } + return sendRequest({ + abortController, + method: 'DELETE', + credentials: true, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'tiers', name]), + headers + }, false); + } + + async getCustomers(abortController: AbortController): Promise> { + return sendRequest({ + abortController, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'customers']), + credentials: true + }, false); + } + + 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(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(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); + } + + /** + * Get usage stats for a customer within an optional date range. + */ + async getUsageStats( + abortController: AbortController, + customerName: string, + date: Date, + ): Promise> { + const query: { key: string, value: string | number }[] = []; + query.push({ key: 'date', value: date.toISOString() }); + + return sendRequest({ + abortController, + endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'ratelimit', 'customers', customerName, 'usage'], query), + credentials: true + }, false); + } } export interface ExtensionFilter { diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index 6d50efb48..60799da76 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -258,3 +258,53 @@ export interface LoginProviders { 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' +} + +export interface Tier { + name: string; + description?: string; + tierType: TierType; + capacity: number; + duration: number; + refillStrategy: RefillStrategy; +} + +export interface TierList { + tiers: Tier[]; +} + +export enum EnforcementState { + EVALUATION = 'EVALUATION', + ENFORCEMENT = 'ENFORCEMENT' +} + +export interface Customer { + name: string; + tier?: Tier; + state: EnforcementState; + cidrBlocks: string[]; +} + +export interface CustomerList { + customers: Customer[]; +} + +export interface UsageStats { + windowStart: number; // epoch seconds in UTC + duration: number; // in seconds + count: number; +} + +export interface UsageStatsList { + stats: UsageStats[]; +} diff --git a/webui/src/pages/admin-dashboard/admin-dashboard.tsx b/webui/src/pages/admin-dashboard/admin-dashboard.tsx index 4df98e83f..427101cfa 100644 --- a/webui/src/pages/admin-dashboard/admin-dashboard.tsx +++ b/webui/src/pages/admin-dashboard/admin-dashboard.tsx @@ -23,6 +23,14 @@ 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 BarChartIcon from '@mui/icons-material/BarChart'; +import { Tiers } from './tiers/tiers'; +import { Customers } from './customers/customers'; +import { UsageStatsView } from './usage-stats/usage-stats'; +import { LoginComponent } from "../../default/login"; +import AccountBoxIcon from "@mui/icons-material/AccountBox"; export namespace AdminDashboardRoutes { export const ROOT = 'admin-dashboard'; @@ -30,6 +38,9 @@ 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']); + export const CUSTOMERS = createRoute([ROOT, 'customers']); + export const USAGE_STATS = createRoute([ROOT, 'usage']); } const Message: FunctionComponent<{message: string}> = ({ message }) => { @@ -59,16 +70,23 @@ export const AdminDashboard: FunctionComponent = props => { } route={AdminDashboardRoutes.NAMESPACE_ADMIN} /> } route={AdminDashboardRoutes.EXTENSION_ADMIN} /> } route={AdminDashboardRoutes.PUBLISHER_ADMIN} /> + } route={AdminDashboardRoutes.TIERS} /> + } route={AdminDashboardRoutes.CUSTOMERS} /> + } route={AdminDashboardRoutes.USAGE_STATS} /> - + } /> } /> } /> + } /> + } /> + } /> + } /> } /> @@ -77,12 +95,37 @@ export const AdminDashboard: FunctionComponent = props => { } else if (user) { content = ; } else if (!props.userLoading && loginProviders) { - content = ; + + content = + + + { + if (href) { + return ( + + ); + } else { + return ( + + ); + } + }} + /> + + ; } return <> - {content} + {content} ; }; 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 new file mode 100644 index 000000000..7be7d947c --- /dev/null +++ b/webui/src/pages/admin-dashboard/customers/customer-form-dialog.tsx @@ -0,0 +1,348 @@ + +/****************************************************************************** + * 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, + DialogTitle, + DialogContent, + DialogActions, + TextField, + Button, + FormControl, + InputLabel, + Select, + MenuItem, + CircularProgress, + Alert, + Box, + Autocomplete, + FormHelperText, + styled +} from '@mui/material'; +import type { SelectChangeEvent } from '@mui/material'; +import { type Customer, EnforcementState, type Tier } from "../../../extension-registry-types"; +import { MainContext } from "../../../context"; +import { handleError } from "../../../utils"; + +interface CustomerFormDialogProps { + open: boolean; + customer?: Customer; + onClose: () => void; + 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 + padding: '2px 6px', + borderRadius: '4px', + fontSize: '0.9em', + color: theme.palette.text.primary, +})); + +export const CustomerFormDialog: FC = ({ open, customer, onClose, onSubmit }) => { + const abortController = useRef(new AbortController()); + const { service } = React.useContext(MainContext); + const [formData, setFormData] = useState({ + name: '', + tier: undefined, + state: EnforcementState.ENFORCEMENT, + cidrBlocks: [] + }); + const [tiers, setTiers] = useState([]); + const [loading, setLoading] = useState(false); + const [errors, setErrors] = useState>({}); + const [touched, setTouched] = useState>({}); + + 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(() => { + loadTiers(); + return () => abortController.current.abort(); + }, []); + + useEffect(() => { + if (customer) { + setFormData({ + name: customer.name, + tier: customer.tier, + state: customer.state, + cidrBlocks: customer.cidrBlocks + }); + } else { + setFormData({ + name: '', + tier: tiers.length > 0 ? tiers[0] : undefined, + state: EnforcementState.ENFORCEMENT, + cidrBlocks: [] + }); + } + setErrors({}); + setTouched({}); + }, [open, customer, tiers]); + + const clearFieldError = (fieldName: string) => { + if (errors[fieldName]) { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors[fieldName]; + return newErrors; + }); + } + }; + + const handleChange = (e: React.ChangeEvent | SelectChangeEvent) => { + const { name, value } = e.target; + clearFieldError(name); + + if (name === 'tierName') { + const tier = tiers.find((tier) => tier.name === value); + setFormData(prev => ({ + ...prev, + tier: tier, + })); + } else { + setFormData(prev => ({ ...prev, [name]: value })); + } + }; + + const handleBlur = (e: React.FocusEvent) => { + const { name } = e.target; + setTouched(prev => ({ ...prev, [name]: true })); + + // Validate the specific field on blur + validateField(name); + }; + + const fieldValidators: Record string | undefined> = { + 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: () => { + if (formData.cidrBlocks && formData.cidrBlocks.length > 0) { + const invalidEntries = formData.cidrBlocks.filter(cidr => !isValidCIDR(cidr.trim())); + if (invalidEntries.length > 0) { + return `Invalid CIDR block(s): ${invalidEntries.join(', ')}`; + } + } + return undefined; + }, + }; + + const validateField = (fieldName: string): string | undefined => { + const validator = fieldValidators[fieldName]; + const error = validator?.(); + + if (error) { + setErrors(prev => ({ ...prev, [fieldName]: error })); + } + return error; + }; + + const isValidCIDR = (cidr: string): boolean => { + const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/; + const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}(\/\d{1,3})?$/; + return ipv4Regex.test(cidr) || ipv6Regex.test(cidr); + }; + + const handleCidrBlocksChange = (event: any, value: string[]) => { + clearFieldError('cidrBlocks'); + + // Validate all entries + const invalidEntries = value.filter(cidr => !isValidCIDR(cidr.trim())); + + if (invalidEntries.length > 0) { + setTouched(prev => ({ ...prev, cidrBlocks: true })); + setErrors(prev => ({ + ...prev, + 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, + cidrBlocks: value.map(cidr => cidr.trim()), + })); + }; + + const validateForm = (): boolean => { + // Mark all fields as touched on submit + setTouched({ + name: true, + tierName: true, + state: true, + cidrBlocks: true, + }); + + const newErrors: Record = {}; + for (const key of Object.keys(formData)) { + const error = validateField(key); + if (error !== undefined) { + newErrors[key] = error; + } + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async () => { + if (!validateForm()) { + return; + } + + setLoading(true); + + try { + await onSubmit(formData); + onClose(); + } catch (err: any) { + setErrors({ submit: handleError(err) }); + } finally { + setLoading(false); + } + }; + + const isEditMode = !!customer; + const title = isEditMode ? 'Edit Customer' : 'Create New Customer'; + + return ( + + {title} + + + {errors.submit && ( + {errors.submit} + )} + + + + + Tier + + {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 + )} + /> + )} + /> + + + + + + + + + + ); +}; 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..641d43031 --- /dev/null +++ b/webui/src/pages/admin-dashboard/customers/customers.tsx @@ -0,0 +1,279 @@ + +/****************************************************************************** + * 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, useCallback, useMemo } from "react"; +import { + Box, + Button, + Paper, + Typography, + CircularProgress, + Alert, + IconButton, + 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"; +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 [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(); + + // Load all customers + const loadCustomers = useCallback(async () => { + try { + setLoading(true); + setError(null); + const data = await service.admin.getCustomers(abortController.current); + setCustomers(data.customers); + } catch (err: any) { + setError(handleError(err)); + } finally { + setLoading(false); + } + }, [service]); + + useEffect(() => { + loadCustomers(); + return () => abortController.current.abort(); + }, []); + + 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 (customer: Customer) => { + if (selectedCustomer) { + // update existing customer + await service.admin.updateCustomer(abortController.current, selectedCustomer.name, customer); + } else { + // create new customer + await service.admin.createCustomer(abortController.current, customer); + } + await loadCustomers(); + }; + + 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); + }; + + // 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: 160, + 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)}> + {error} + + )} + + {loading && ( + + + + )} + + { !loading && customers.length === 0 && ( + + + No customers found. Create one to get started. + + + )} + + { !loading && customers.length > 0 && ( + + row.name} + pageSizeOptions={[20, 35, 50]} + initialState={{ + pagination: { paginationModel: { pageSize: 20 } }, + }} + disableRowSelectionOnClick + sx={{ + flex: 1, + }} + /> + + )} + + + + + + ); +}; 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..6456784af --- /dev/null +++ b/webui/src/pages/admin-dashboard/customers/delete-customer-dialog.tsx @@ -0,0 +1,94 @@ + +/****************************************************************************** + * 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, + 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"; +import { handleError } from "../../../utils"; + +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(handleError(err)); + setLoading(false); + } + }; + + if (!customer) return null; + + return ( + + + + + Delete Customer + + + + {error && {error}} + + Are you sure you want to delete the customer {customer.name}? + + {customer.tier?.name && ( + + This customer is currently using the {customer.tier?.name} tier. + + )} + + This action cannot be undone. All associated usage statistics will also be deleted. + + + + + + + + ); +}; 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..698f1dc69 --- /dev/null +++ b/webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.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, useState } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, + CircularProgress, + Alert +} from '@mui/material'; +import type { Tier } from '../../../extension-registry-types'; +import { handleError } from "../../../utils"; + +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(handleError(err)); + } finally { + 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..36c80fbf8 --- /dev/null +++ b/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx @@ -0,0 +1,372 @@ +/****************************************************************************** + * 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, useEffect, useState} from 'react'; +import type {SelectChangeEvent} from '@mui/material'; +import { + Alert, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + FormHelperText, + InputLabel, + MenuItem, + Select, + TextField +} from '@mui/material'; +import {RefillStrategy, type Tier, TierType} from "../../../extension-registry-types"; +import {handleError} from "../../../utils"; + +type DurationUnit = 'seconds' | 'minutes' | 'hours'; + +const DURATION_MULTIPLIERS: Record = { + seconds: 1, + minutes: 60, + 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; + onClose: () => void; + onSubmit: (formData: Tier) => Promise; +} + +export const TierFormDialog: FC = ({ open, tier, onClose, onSubmit }) => { + 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); + const [errors, setErrors] = useState>({}); + const [touched, setTouched] = useState>({}); + + const getDurationInSeconds = (): number => { + return durationValue * DURATION_MULTIPLIERS[durationUnit]; + }; + + useEffect(() => { + if (tier) { + setFormData(_ => ({ + name: tier.name, + description: tier.description || '', + tierType: tier.tierType, + capacity: tier.capacity, + duration: tier.duration, + refillStrategy: tier.refillStrategy + } as Tier)); + // Convert duration seconds to value/unit for display + const [value, unit] = formatDuration(tier.duration); + setDurationValue(value); + setDurationUnit(unit); + } else { + setFormData(prev => ({ + ...prev, + name: '', + description: '', + tierType: TierType.NON_FREE, + capacity: 100, + duration: 3600, + refillStrategy: RefillStrategy.INTERVAL + })); + setDurationValue(1); + setDurationUnit('hours'); + } + setErrors({}); + setTouched({}); + }, [open, tier]); + + const clearFieldError = (fieldName: string) => { + if (errors[fieldName]) { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors[fieldName]; + return newErrors; + }); + } + }; + + const handleChange = (e: React.ChangeEvent | SelectChangeEvent) => { + const { name, value } = e.target as any; + clearFieldError(name); + + setFormData((prev: Tier) => ({ + ...prev, + [name]: name === 'capacity' || name === 'duration' ? Number.parseInt(value as string, 10) : value + } as Tier)); + }; + + const handleBlur = (e: React.FocusEvent) => { + const { name } = e.target; + setTouched(prev => ({ ...prev, [name]: true })); + validateField(name); + }; + + const fieldValidators: Record string | undefined> = { + 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', + }; + + const validateField = (fieldName: string): string | undefined => { + const validator = fieldValidators[fieldName]; + const error = validator?.(); + + if (error) { + setErrors(prev => ({ ...prev, [fieldName]: error })); + } + return error; + }; + + const validateForm = (): boolean => { + // Mark all fields as touched on submit + setTouched({ + name: true, + tierType: true, + capacity: true, + duration: true, + refillStrategy: true, + }); + + const newErrors: Record = {}; + for (const key of Object.keys(formData)) { + const error = validateField(key); + if (error !== undefined) { + newErrors[key] = error; + } + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async () => { + if (!validateForm()) { + return; + } + + setLoading(true); + + try { + const durationInSeconds = getDurationInSeconds(); + await onSubmit({ + ...formData, + duration: durationInSeconds + }); + onClose(); + } catch (err: any) { + setErrors({ submit: handleError(err) }); + } finally { + setLoading(false); + } + }; + + const isEditMode = !!tier; + const title = isEditMode ? 'Edit Tier' : 'Create New Tier'; + + return ( + + {title} + + + {errors.submit && {errors.submit}} + + + + + + + Tier Type + + {touched.tierType && errors.tierType && {errors.tierType}} + + + + + + { + clearFieldError('duration'); + setDurationValue(Math.max(1, Number.parseInt(e.target.value, 10) || 0)); + }} + onBlur={(e) => { + setTouched(prev => ({ ...prev, duration: true })); + validateField('duration'); + }} + inputProps={{ min: '1' }} + disabled={loading} + required={true} + error={touched.duration && !!errors.duration} + helperText={touched.duration && errors.duration} + sx={{ flex: 1 }} + /> + + Unit + + + + + = {getDurationInSeconds().toLocaleString()} seconds + + + + Refill Strategy + + {touched.refillStrategy && errors.refillStrategy && {errors.refillStrategy}} + + + + + + + + + + + + ); +}; 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..346e80084 --- /dev/null +++ b/webui/src/pages/admin-dashboard/tiers/tiers.tsx @@ -0,0 +1,254 @@ +/****************************************************************************** + * 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 } from "react"; +import { + Box, + Button, + Paper, + Typography, + CircularProgress, + Alert, + IconButton +} from "@mui/material"; +import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; +import EditIcon from "@mui/icons-material/Edit"; +import DeleteIcon from "@mui/icons-material/Delete"; +import AddIcon from "@mui/icons-material/Add"; +import { 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 { createMultiSelectFilterOperators } from "../components"; + +export const Tiers: FC = () => { + const abortController = useRef(new AbortController()); + 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.getTiers(abortController.current); + setTiers(data.tiers); + } catch (err: any) { + setError(handleError(err)); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadTiers(); + return () => abortController.current.abort(); + }, []); + + 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: 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 () => { + if (selectedTier) { + await service.admin.deleteTier(abortController.current, selectedTier.name); + await loadTiers(); + } + }; + + const handleFormDialogClose = () => { + setFormDialogOpen(false); + setSelectedTier(undefined); + }; + + const handleDeleteDialogClose = () => { + setDeleteDialogOpen(false); + setSelectedTier(undefined); + }; + + // 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] + ); + + const columns: GridColDef[] = [ + { + field: 'name', + headerName: 'Name', + flex: 1, + minWidth: 150 + }, + { + field: 'description', + headerName: 'Description', + flex: 2, + minWidth: 200, + valueGetter: (value: string) => value || '-' + }, + { + field: 'tierType', + headerName: 'Tier Type', + width: 150, + filterOperators: createMultiSelectFilterOperators(tierTypeOptions) + }, + { + 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 + + + + + { error && + setError(null)}> + {error} + + } + + { loading && + + + + } + + { !loading && tiers.length === 0 && + + + No tiers found. Create one to get started. + + + } + + { !loading && tiers.length > 0 && + + row.name} + pageSizeOptions={[20, 35, 50]} + initialState={{ + pagination: { paginationModel: { pageSize: 20 } }, + }} + disableRowSelectionOnClick + sx={{ flex: 1 }} + /> + + } + + + + + + ); +}; 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..3c74ce375 --- /dev/null +++ b/webui/src/pages/admin-dashboard/usage-stats/usage-stats-chart.tsx @@ -0,0 +1,189 @@ +/****************************************************************************** + * 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, + Stack +} from "@mui/material"; +import {BarPlot} from "@mui/x-charts/BarChart"; +import { DatePicker } from "@mui/x-date-pickers/DatePicker"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns"; +import type {Customer, UsageStats } from "../../../extension-registry-types"; +import {addDays, format, startOfDay} from "date-fns"; +import { + ChartsReferenceLine, + ChartsTooltip, + ChartsXAxis, + ChartsYAxis, + ResponsiveChartContainer +} from "@mui/x-charts"; + +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() / 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[] = []; + + for (let idx = dayStart; idx < dayEnd; idx += step) { + arr.push({ + windowStart: idx, + duration: step, + count: 0 + }) + } + + for (const stat of usageStats) { + 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] + ); + + return ( + + + + Filters + + + + + + + {usageStats.length === 0 ? + No usage data available for this customer. + : + <> + + d.count), + label: 'Request Count', + color: 'lightgray', + }]} + + height={400} + margin={{ top: 10 }} + xAxis={[ + { + id: 'date', + data: data.map((value) => new Date(value.windowStart * 1000)), + scaleType: 'band', + valueFormatter: (value) => format(new Date(value), 'HH:mm'), + }, + ]} + yAxis={[ + { + id: 'requests', + scaleType: 'linear', + min: 0, + max: Math.max(tierCapacity, maxDataValue) + 10 + }, + ]} + > + + + {tierCapacity > 0 && + + } + + { + return new Date(value).getMinutes() === 0; + }} + tickLabelInterval={(value, index) => { + return new Date(value).getMinutes() === 0; + }} + tickLabelStyle={{ + fontSize: 10, + }} + /> + + + + + + + + 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..61563b13f --- /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..c36de3302 --- /dev/null +++ b/webui/src/pages/admin-dashboard/usage-stats/usage-stats-utils.ts @@ -0,0 +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 + *****************************************************************************/ + +export const getDefaultStartDate = () => { + 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 new file mode 100644 index 000000000..8fb8808d3 --- /dev/null +++ b/webui/src/pages/admin-dashboard/usage-stats/usage-stats.tsx @@ -0,0 +1,127 @@ +/****************************************************************************** + * 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 } from "@mui/material"; +import { useParams, useNavigate } from "react-router-dom"; +import { MainContext } from "../../../context"; +import type { UsageStats, 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 [startDate, setStartDate] = useState(getDefaultStartDate); + + // 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 + ); + setUsageStats(data.stats); + } catch (err) { + setError(handleError(err as Error)); + } finally { + setLoading(false); + } + }, [service, customer, startDate]); + + useEffect(() => { + if (customer) { + loadUsageStats(); + } + }, [loadUsageStats, customer]); + + if (error) { + return {error}; + } + + return ( + + + ]} + listContainer={!customer && Select a customer to view usage statistics.} + loading={loading || customersLoading} + /> + {customer && ( + )} + + ); +}; diff --git a/webui/src/pages/admin-dashboard/welcome.tsx b/webui/src/pages/admin-dashboard/welcome.tsx index 1ab0d42ed..f95d3b82c 100644 --- a/webui/src/pages/admin-dashboard/welcome.tsx +++ b/webui/src/pages/admin-dashboard/welcome.tsx @@ -27,6 +27,9 @@ export const Welcome: FunctionComponent = props => { + + + diff --git a/webui/src/server-request.ts b/webui/src/server-request.ts index 519381146..8e3e33f84 100644 --- a/webui/src/server-request.ts +++ b/webui/src/server-request.ts @@ -28,7 +28,7 @@ export interface ErrorResponse { trace?: string; } -export async function sendRequest(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) { diff --git a/webui/yarn.lock b/webui/yarn.lock index 5d0ff7eec..b92ce700f 100644 --- a/webui/yarn.lock +++ b/webui/yarn.lock @@ -287,6 +287,13 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.28.4": + version: 7.28.6 + resolution: "@babel/runtime@npm:7.28.6" + checksum: 10/fbcd439cb74d4a681958eb064c509829e3f46d8a4bfaaf441baa81bb6733d1e680bccc676c813883d7741bcaada1d0d04b15aa320ef280b5734e2192b50decf9 + languageName: node + linkType: hard + "@babel/template@npm:^7.25.0": version: 7.25.0 resolution: "@babel/template@npm:7.25.0" @@ -788,7 +795,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: @@ -944,6 +951,52 @@ __metadata: languageName: node linkType: hard +"@mui/types@npm:^7.4.10": + version: 7.4.10 + resolution: "@mui/types@npm:7.4.10" + dependencies: + "@babel/runtime": "npm:^7.28.4" + peerDependencies: + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/7492c6aa0b55e1d3c2bcbac7d3e0a41fe684aa5927a26911d9b6bef5d1ad85e578614af5da1f61e7ea0b37318d3c595bff29ae9af6f345572ad8d23cbd2211d0 + 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" @@ -962,6 +1015,26 @@ __metadata: languageName: node linkType: hard +"@mui/utils@npm:^5.16.6 || ^6.0.0 || ^7.0.0": + version: 7.3.7 + resolution: "@mui/utils@npm:7.3.7" + dependencies: + "@babel/runtime": "npm:^7.28.4" + "@mui/types": "npm:^7.4.10" + "@types/prop-types": "npm:^15.7.15" + clsx: "npm:^2.1.1" + prop-types: "npm:^15.8.1" + react-is: "npm:^19.2.3" + 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/8b4110df220c7a3f4feee4e88b870fef20ac22499d24d572597f78ea6b586e28b19aac7f7cd2ed0b500d1b0b54b3b8b57d8d63cfa4f5a5cbae1c4527fbd77cbe + languageName: node + linkType: hard + "@mui/utils@npm:^6.0.0-dev.20240529-082515-213b5e33ab": version: 6.0.0-dev.20240529-082515-213b5e33ab resolution: "@mui/utils@npm:6.0.0-dev.20240529-082515-213b5e33ab" @@ -980,6 +1053,122 @@ __metadata: languageName: node linkType: hard +"@mui/x-charts@npm:^6.19": + 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.29": + version: 7.29.12 + resolution: "@mui/x-data-grid@npm:7.29.12" + dependencies: + "@babel/runtime": "npm:^7.25.7" + "@mui/utils": "npm:^5.16.6 || ^6.0.0 || ^7.0.0" + "@mui/x-internals": "npm:7.29.0" + clsx: "npm:^2.1.1" + prop-types: "npm:^15.8.1" + reselect: "npm:^5.1.1" + use-sync-external-store: "npm:^1.0.0" + peerDependencies: + "@emotion/react": ^11.9.0 + "@emotion/styled": ^11.8.1 + "@mui/material": ^5.15.14 || ^6.0.0 || ^7.0.0 + "@mui/system": ^5.15.14 || ^6.0.0 || ^7.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/react": + optional: true + "@emotion/styled": + optional: true + checksum: 10/a5557d85beb9991970b47b83303142342b3d223c568c174ddf61d5bbad2b2b48ed132d875274837a062c0bc76d5c65fd45d30ba21618a7ca77b0739caa59a96f + languageName: node + linkType: hard + +"@mui/x-date-pickers@npm:^6.20": + 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" + dependencies: + "@babel/runtime": "npm:^7.25.7" + "@mui/utils": "npm:^5.16.6 || ^6.0.0 || ^7.0.0" + peerDependencies: + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10/53ab96dd7719f2c18488c648ade3e45d58028fbbb99da9c3199f9190bcbdbedb06e7e397338b261ef0738d7973c66a6a43cffe4047e18919e210b67bfbe8ea87 + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -1054,6 +1243,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.23.2": version: 1.23.2 resolution: "@remix-run/router@npm:1.23.2" @@ -1171,6 +1426,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" @@ -1307,6 +1594,13 @@ __metadata: languageName: node linkType: hard +"@types/prop-types@npm:^15.7.15": + version: 15.7.15 + resolution: "@types/prop-types@npm:15.7.15" + checksum: 10/31aa2f59b28f24da6fb4f1d70807dae2aedfce090ec63eaf9ea01727a9533ef6eaf017de5bff99fbccad7d1c9e644f52c6c2ba30869465dd22b1a7221c29f356 + languageName: node + linkType: hard + "@types/punycode@npm:^2.1.0": version: 2.1.4 resolution: "@types/punycode@npm:2.1.4" @@ -1385,6 +1679,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" @@ -2384,7 +2687,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 @@ -2575,6 +2878,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" @@ -2608,6 +2990,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" @@ -4009,6 +4400,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" @@ -5087,10 +5485,16 @@ __metadata: "@mui/base": "npm:^5.0.0-beta.9" "@mui/icons-material": "npm:^5.13.7" "@mui/material": "npm:^5.13.7" + "@mui/system": "npm:^5.15.15" + "@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" @@ -5111,6 +5515,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" @@ -5642,6 +6047,20 @@ __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" + checksum: 10/547ac397308204742447b5e4cf556cf09aa97f4cc1c95c4a28e4b2e73a7efab03e59d7b89b4a01f67bf9bf0ca87348a7f0e88bc87c0af6be458aaac1dec5e132 + languageName: node + linkType: hard + "react-router-dom@npm:^6.30.3": version: 6.30.3 resolution: "react-router-dom@npm:6.30.3" @@ -5759,6 +6178,13 @@ __metadata: languageName: node linkType: hard +"reselect@npm:^5.1.1": + version: 5.1.1 + resolution: "reselect@npm:5.1.1" + checksum: 10/1fdae11a39ed9c8d85a24df19517c8372ee24fefea9cce3fae9eaad8e9cefbba5a3d4940c6fe31296b6addf76e035588c55798f7e6e147e1b7c0855f119e7fa5 + languageName: node + linkType: hard + "resolve-cwd@npm:^3.0.0": version: 3.0.0 resolution: "resolve-cwd@npm:3.0.0" @@ -6718,6 +7144,15 @@ __metadata: languageName: node linkType: hard +"use-sync-external-store@npm:^1.0.0": + version: 1.6.0 + resolution: "use-sync-external-store@npm:1.6.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10/b40ad2847ba220695bff2d4ba4f4d60391c0fb4fb012faa7a4c18eb38b69181936f5edc55a522c4d20a788d1a879b73c3810952c9d0fd128d01cb3f22042c09e + languageName: node + linkType: hard + "util-deprecate@npm:^1.0.2": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2"