From d37dd81fb9b8333c920bd062492fca294dde6a85 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 16:48:42 +0100 Subject: [PATCH 1/2] feat(keys): count each shared key's calls against its window and daily or monthly limit --- .../catapult/getter/KeyCallCounter.java | 69 +++++++++++++++++++ .../getter/dtdd/DtddApiKeyRotator.java | 53 ++++++++++++-- .../getter/dtdd/RealDtddApiClient.java | 5 +- .../getter/steam/SteamRateLimiter.java | 31 ++++++++- .../service/dtdd/DtddKeyPoolService.java | 3 +- .../service/steam/SteamKeyPoolService.java | 13 ++-- .../ApiAdminSteamKeysControllerTest.java | 8 ++- .../catapult/getter/KeyCallCounterTest.java | 33 +++++++++ .../getter/dtdd/DtddApiKeyRotatorTest.java | 18 +++++ .../getter/dtdd/RealDtddApiClientTest.java | 4 +- .../getter/steam/SteamRateLimiterTest.java | 15 ++++ .../common/dto/admin/ApiKeysPageDto.java | 13 +++- .../catapult/common/dto/admin/KeyStatus.java | 10 ++- .../common/dto/admin/KeyUsageDto.java | 10 +++ .../catapult/common/dto/AdminDtosTest.java | 14 ++++ 15 files changed, 280 insertions(+), 19 deletions(-) create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/getter/KeyCallCounter.java create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/getter/KeyCallCounterTest.java create mode 100644 catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/KeyUsageDto.java diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/getter/KeyCallCounter.java b/catapult-api/src/main/java/fr/enimaloc/catapult/getter/KeyCallCounter.java new file mode 100644 index 000000000..4adffba6f --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/getter/KeyCallCounter.java @@ -0,0 +1,69 @@ +package fr.enimaloc.catapult.getter; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +/** + * The calls sent with each shared API key, for the admin Keys tab: those in the last {@code window} and those in the + * current period (a day, a month: what {@code periodOf} names an instant's). Counted on this instance only. + */ +public final class KeyCallCounter { + + private final Duration window; + private final Function periodOf; + private final Map byKey = new ConcurrentHashMap<>(); + + private static final class Counts { + final Deque recent = new ArrayDeque<>(); + Object period; + long inPeriod; + } + + public KeyCallCounter(Duration window, Function periodOf) { + this.window = window; + this.periodOf = periodOf; + } + + public void record(String key, Instant now) { + Counts c = byKey.computeIfAbsent(key, k -> new Counts()); + synchronized (c) { + c.recent.addLast(now); + prune(c, now); + Object period = periodOf.apply(now); + if (!period.equals(c.period)) { + c.period = period; + c.inPeriod = 0; + } + c.inPeriod++; + } + } + + /** The calls sent with {@code key} in the last window. */ + public int inWindow(String key, Instant now) { + Counts c = byKey.get(key); + if (c == null) return 0; + synchronized (c) { + prune(c, now); + return c.recent.size(); + } + } + + /** The calls sent with {@code key} in the current period. */ + public long inPeriod(String key, Instant now) { + Counts c = byKey.get(key); + if (c == null) return 0; + synchronized (c) { + return periodOf.apply(now).equals(c.period) ? c.inPeriod : 0; + } + } + + private void prune(Counts c, Instant now) { + Instant from = now.minus(window); + while (!c.recent.isEmpty() && !c.recent.peekFirst().isAfter(from)) c.recent.removeFirst(); + } +} diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/getter/dtdd/DtddApiKeyRotator.java b/catapult-api/src/main/java/fr/enimaloc/catapult/getter/dtdd/DtddApiKeyRotator.java index 33da0c9e0..afd91d45c 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/getter/dtdd/DtddApiKeyRotator.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/getter/dtdd/DtddApiKeyRotator.java @@ -2,12 +2,20 @@ import fr.enimaloc.catapult.domain.dtdd.DtddApiKeyEntry; import fr.enimaloc.catapult.repository.dtdd.DtddApiKeyRepository; +import fr.enimaloc.catapult.common.dto.admin.KeyUsageDto; +import fr.enimaloc.catapult.getter.KeyCallCounter; import io.micrometer.core.instrument.Gauge; import io.micrometer.core.instrument.MeterRegistry; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.stereotype.Component; +import java.time.Duration; +import java.time.Instant; +import java.time.YearMonth; +import java.time.ZoneOffset; import java.util.Comparator; import java.util.List; import java.util.Map; @@ -26,6 +34,11 @@ public class DtddApiKeyRotator { private final Map quotas = new ConcurrentHashMap<>(); private final AtomicInteger counter = new AtomicInteger(0); private volatile List keys = List.of(); + private final KeyCallCounter calls = + new KeyCallCounter(Duration.ofMinutes(1), at -> YearMonth.from(at.atZone(ZoneOffset.UTC))); + /** A key's limits when DTDD doesn't tell them ({@code X-RateLimit-Limit-*}): its free tier's. */ + @Setter @Value("${dtdd.rate-limit.per-minute:30}") private int defaultPerMinute = 30; + @Setter @Value("${dtdd.rate-limit.per-month:5000}") private long defaultPerMonth = 5000; public DtddApiKeyRotator(DtddApiKeyRepository repository, MeterRegistry meterRegistry) { this.repository = repository; @@ -90,24 +103,56 @@ public boolean isAllKeysBlocked() { } /** - * What DTDD said was left of a key's quota with its last answer ({@code X-RateLimit-Remaining-*}), null when it - * didn't say; {@code at} when it said it (epoch ms). + * What DTDD said was left of a key's quota with its last answer ({@code X-RateLimit-Remaining-*}), and its limits + * ({@code X-RateLimit-Limit-*}), null when it didn't say; {@code at} when it said it (epoch ms). */ - public record Quota(Integer remainingMinute, Integer remainingMonth, long at) {} + public record Quota(Integer remainingMinute, Integer remainingMonth, long at, Integer limitMinute, + Integer limitMonth) { + + /** What was left, without the limits. */ + public Quota(Integer remainingMinute, Integer remainingMonth, long at) { + this(remainingMinute, remainingMonth, at, null, null); + } + } /** * Remembers what DTDD said was left of {@code key}'s quota. Nothing left this minute: the key rests until the * next one instead of asking for a 429. */ public void onQuota(String key, Integer remainingMinute, Integer remainingMonth) { + onQuota(key, remainingMinute, remainingMonth, null, null); + } + + /** As {@link #onQuota(String, Integer, Integer)}, with the limits DTDD told (null: not told). */ + public void onQuota(String key, Integer remainingMinute, Integer remainingMonth, Integer limitMinute, + Integer limitMonth) { if (remainingMinute == null && remainingMonth == null) return; long now = System.currentTimeMillis(); - quotas.put(key, new Quota(remainingMinute, remainingMonth, now)); + quotas.put(key, new Quota(remainingMinute, remainingMonth, now, limitMinute, limitMonth)); if (remainingMinute != null && remainingMinute <= 0) { keyBlockedUntil.merge(key, now + 60_000L, Math::max); } } + /** A call about to be sent with {@code key}: counted for the admin Keys tab. */ + public void onCall(String key) { + calls.record(key, Instant.now()); + } + + /** + * What {@code key} was used for on this instance: its calls of the last minute and of the month (UTC), each out + * of the limit DTDD told (else the free tier's), with what DTDD said was left (the minute's only while fresh). + */ + public KeyUsageDto usage(String key) { + Instant now = Instant.now(); + Quota q = quotas.get(key); + boolean fresh = q != null && now.toEpochMilli() - q.at() < 60_000L; + Integer limitMinute = q != null && q.limitMinute() != null ? q.limitMinute() : defaultPerMinute; + Long limitMonth = q != null && q.limitMonth() != null ? (long) q.limitMonth() : defaultPerMonth; + return new KeyUsageDto(calls.inWindow(key, now), limitMinute, 60, fresh ? q.remainingMinute() : null, + "MONTH", calls.inPeriod(key, now), limitMonth, q == null ? null : q.remainingMonth()); + } + /** Each key's last known quota (keys DTDD never answered for are absent). */ public Map getQuotas() { return Map.copyOf(quotas); diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/getter/dtdd/RealDtddApiClient.java b/catapult-api/src/main/java/fr/enimaloc/catapult/getter/dtdd/RealDtddApiClient.java index 5d2851fc7..8c3062e8b 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/getter/dtdd/RealDtddApiClient.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/getter/dtdd/RealDtddApiClient.java @@ -118,9 +118,12 @@ private Optional callWithRetry(Function> c if (keyOpt.isEmpty()) return Optional.empty(); String key = keyOpt.get(); try { + rotator.onCall(key); ResponseEntity response = call.apply(key); rotator.onQuota(key, header(response.getHeaders(), "X-RateLimit-Remaining-Minute"), - header(response.getHeaders(), "X-RateLimit-Remaining-Month")); + header(response.getHeaders(), "X-RateLimit-Remaining-Month"), + header(response.getHeaders(), "X-RateLimit-Limit-Minute"), + header(response.getHeaders(), "X-RateLimit-Limit-Month")); return Optional.ofNullable(parser.apply(response.getBody())); } catch (RestClientResponseException e) { HttpStatusCode status = e.getStatusCode(); diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/getter/steam/SteamRateLimiter.java b/catapult-api/src/main/java/fr/enimaloc/catapult/getter/steam/SteamRateLimiter.java index d2f225322..1b3e02860 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/getter/steam/SteamRateLimiter.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/getter/steam/SteamRateLimiter.java @@ -1,13 +1,20 @@ package fr.enimaloc.catapult.getter.steam; +import fr.enimaloc.catapult.common.dto.admin.KeyUsageDto; +import fr.enimaloc.catapult.getter.KeyCallCounter; import io.micrometer.core.instrument.Gauge; import io.micrometer.core.instrument.MeterRegistry; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; @@ -28,6 +35,10 @@ public class SteamRateLimiter { private final ConcurrentHashMap keyPermits = new ConcurrentHashMap<>(); private final ConcurrentHashMap keyBlockedUntil = new ConcurrentHashMap<>(); private volatile long globalBlockedUntil = 0; + /** Steam's documented daily limit per key (100,000 calls), shown against the calls of the day. */ + @Setter @Value("${steam.rate-limit.daily-limit:100000}") private long dailyLimit = 100_000; + private final KeyCallCounter calls = + new KeyCallCounter(Duration.ofSeconds(1), at -> LocalDate.ofInstant(at, ZoneOffset.UTC)); public SteamRateLimiter( @Value("${steam.rate-limit.permits-per-window:3}") int permitsPerWindow, @@ -67,7 +78,7 @@ public boolean acquire(String key) { long now = System.currentTimeMillis(); if (now < globalBlockedUntil || now < keyBlockedUntil.getOrDefault(key, 0L)) return false; try { - return semaphoreFor(key).tryAcquire(maxWaitMs, TimeUnit.MILLISECONDS); + return counted(key, semaphoreFor(key).tryAcquire(maxWaitMs, TimeUnit.MILLISECONDS)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; @@ -80,7 +91,7 @@ public boolean acquire(String key) { public boolean acquireBlocking(String key) { try { semaphoreFor(key).acquire(); - return true; + return counted(key, true); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; @@ -130,6 +141,22 @@ void replenish() { }); } + /** + * What {@code key} was used for on this instance: the permits taken in the current window out of + * {@code permitsPerWindow}, and the calls of the day (UTC) out of Steam's daily limit. + */ + public KeyUsageDto usage(String key) { + Semaphore permits = keyPermits.get(key); + int used = permits == null ? 0 : Math.max(0, permitsPerWindow - permits.availablePermits()); + return new KeyUsageDto(used, permitsPerWindow, (int) Math.max(1, windowMs / 1000), null, + "DAY", calls.inPeriod(key, Instant.now()), dailyLimit, null); + } + + private boolean counted(String key, boolean acquired) { + if (acquired) calls.record(key, Instant.now()); + return acquired; + } + private Semaphore semaphoreFor(String key) { return keyPermits.computeIfAbsent(key, k -> new Semaphore(permitsPerWindow, true)); } diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/dtdd/DtddKeyPoolService.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/dtdd/DtddKeyPoolService.java index 2cabf1d0c..11555a210 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/service/dtdd/DtddKeyPoolService.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/dtdd/DtddKeyPoolService.java @@ -53,7 +53,8 @@ public DtddKeysPageData page() { long until = blockedUntil.getOrDefault(key, 0L); boolean blocked = until > now; long remainingSec = blocked ? TimeUnit.MILLISECONDS.toSeconds(until - now) : 0L; - keys.add(new KeyStatus(ApiKeyHasher.id(key), masked, owner, blocked, remainingSec)); + keys.add(new KeyStatus(ApiKeyHasher.id(key), masked, owner, blocked, remainingSec, + rotator.map(r -> r.usage(key)).orElse(null))); } return new DtddKeysPageData(keys, rotator.isPresent()); } diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/steam/SteamKeyPoolService.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/steam/SteamKeyPoolService.java index ae2586ce9..3a5204ca4 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/service/steam/SteamKeyPoolService.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/steam/SteamKeyPoolService.java @@ -4,6 +4,7 @@ import fr.enimaloc.catapult.common.dto.admin.SteamKeysPageData; import fr.enimaloc.catapult.domain.steam.SteamApiKeyEntry; import fr.enimaloc.catapult.getter.steam.SteamApiKeyRotator; +import fr.enimaloc.catapult.getter.steam.SteamRateLimiter; import fr.enimaloc.catapult.repository.steam.SteamApiKeyRepository; import fr.enimaloc.catapult.service.ApiKeyHasher; import fr.enimaloc.catapult.service.notification.AdminEventPublisher; @@ -23,24 +24,28 @@ public class SteamKeyPoolService { private final SteamApiKeyRepository repository; private final Optional rotator; private final AdminEventPublisher events; + private final Optional rateLimiter; - /** The rotator only exists when Steam is enabled. */ + /** The rotator and the rate limiter (each key's usage) only exist when Steam is enabled. */ public SteamKeyPoolService(SteamApiKeyRepository repository, Optional rotator, - AdminEventPublisher events) { + AdminEventPublisher events, + Optional rateLimiter) { this.repository = repository; this.rotator = rotator; this.events = events; + this.rateLimiter = rateLimiter; } /** A shared key's id, masked value, owner and remaining rate-limit block. */ - private static KeyStatus status(SteamApiKeyEntry entry, Map blockedUntil, long now) { + private KeyStatus status(SteamApiKeyEntry entry, Map blockedUntil, long now) { String key = entry.getApiKey(); String owner = entry.getOwner() != null ? entry.getOwner().getTwitchUsername() : null; long until = blockedUntil.getOrDefault(key, 0L); boolean blocked = until > now; long remainingSec = blocked ? TimeUnit.MILLISECONDS.toSeconds(until - now) : 0L; - return new KeyStatus(ApiKeyHasher.id(key), mask(key), owner, blocked, remainingSec); + return new KeyStatus(ApiKeyHasher.id(key), mask(key), owner, blocked, remainingSec, + rateLimiter.map(l -> l.usage(key)).orElse(null)); } private void refreshRotator() { diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminSteamKeysControllerTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminSteamKeysControllerTest.java index c1b2ba19a..f428ff2e1 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminSteamKeysControllerTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminSteamKeysControllerTest.java @@ -38,7 +38,11 @@ class ApiAdminSteamKeysControllerTest { private final SteamApiKeyRepository repository = mock(SteamApiKeyRepository.class); private final SteamApiKeyRotator rotator = mock(SteamApiKeyRotator.class); private final AdminEventPublisher events = mock(AdminEventPublisher.class); - private final ApiAdminSteamKeysController controller = new ApiAdminSteamKeysController(new SteamKeyPoolService(repository, Optional.of(rotator), events)); + private final fr.enimaloc.catapult.getter.steam.SteamRateLimiter limiter = + new fr.enimaloc.catapult.getter.steam.SteamRateLimiter(3, 2000, 5000, + new io.micrometer.core.instrument.simple.SimpleMeterRegistry()); + private final ApiAdminSteamKeysController controller = new ApiAdminSteamKeysController( + new SteamKeyPoolService(repository, Optional.of(rotator), events, Optional.of(limiter))); @BeforeEach void setUp() { @@ -79,7 +83,7 @@ void page_masksKeys_andShowsOwnersAndBlocks() { @Test void withoutRotator_steamIsReportedDisabled_andEditsStillWork() { - ApiAdminSteamKeysController bare = new ApiAdminSteamKeysController(new SteamKeyPoolService(repository, Optional.empty(), events)); + ApiAdminSteamKeysController bare = new ApiAdminSteamKeysController(new SteamKeyPoolService(repository, Optional.empty(), events, Optional.empty())); when(repository.findByExclusiveFalseWithOwner()).thenReturn(List.of(entry(KEY, null))); when(repository.findByExclusiveFalse()).thenReturn(List.of(entry(KEY, null))); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/getter/KeyCallCounterTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/getter/KeyCallCounterTest.java new file mode 100644 index 000000000..2458d44f3 --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/getter/KeyCallCounterTest.java @@ -0,0 +1,33 @@ +package fr.enimaloc.catapult.getter; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; + +import static org.assertj.core.api.Assertions.assertThat; + +class KeyCallCounterTest { + + private static final Instant T0 = Instant.parse("2026-11-12T23:59:50Z"); + + @Test + void callsAreCountedInTheWindow_andInTheirDay() { + KeyCallCounter counter = new KeyCallCounter(Duration.ofSeconds(5), at -> LocalDate.ofInstant(at, ZoneOffset.UTC)); + counter.record("k", T0); + counter.record("k", T0.plusSeconds(3)); + + assertThat(counter.inWindow("k", T0.plusSeconds(4))).isEqualTo(2); + assertThat(counter.inWindow("k", T0.plusSeconds(6))).isEqualTo(1); + assertThat(counter.inPeriod("k", T0.plusSeconds(6))).isEqualTo(2); + assertThat(counter.inWindow("other", T0)).isZero(); + assertThat(counter.inPeriod("other", T0)).isZero(); + + // A new day counts afresh + assertThat(counter.inPeriod("k", T0.plusSeconds(20))).isZero(); + counter.record("k", T0.plusSeconds(20)); + assertThat(counter.inPeriod("k", T0.plusSeconds(21))).isEqualTo(1); + } +} diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/getter/dtdd/DtddApiKeyRotatorTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/getter/dtdd/DtddApiKeyRotatorTest.java index 7de636f59..b49d019e3 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/getter/dtdd/DtddApiKeyRotatorTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/getter/dtdd/DtddApiKeyRotatorTest.java @@ -159,4 +159,22 @@ void aQuotaWithoutNumbers_isNotRemembered() { rotator.onQuota("KEY_A", null, null); assertThat(rotator.getQuotas()).isEmpty(); } + + @Test + void aKeysUsage_countsItsCalls_againstWhatDtddTold_elseTheFreeTier() { + assertThat(rotator.usage("KEY_A")).isEqualTo(new fr.enimaloc.catapult.common.dto.admin.KeyUsageDto( + 0, 30, 60, null, "MONTH", 0, 5000L, null)); + + rotator.onCall("KEY_A"); + rotator.onCall("KEY_A"); + rotator.onQuota("KEY_A", 28, 4321, 60, 10000); + + var usage = rotator.usage("KEY_A"); + assertThat(usage.windowCalls()).isEqualTo(2); + assertThat(usage.windowLimit()).isEqualTo(60); + assertThat(usage.windowRemaining()).isEqualTo(28); + assertThat(usage.periodCalls()).isEqualTo(2); + assertThat(usage.periodLimit()).isEqualTo(10000L); + assertThat(usage.periodRemaining()).isEqualTo(4321); + } } diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/getter/dtdd/RealDtddApiClientTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/getter/dtdd/RealDtddApiClientTest.java index 863ae9e14..050d43cff 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/getter/dtdd/RealDtddApiClientTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/getter/dtdd/RealDtddApiClientTest.java @@ -180,11 +180,13 @@ void everyAnswer_tellsTheRotatorWhatIsLeftOfTheKey() { .setHeader("Content-Type", "application/json") .setHeader("X-RateLimit-Remaining-Minute", "28") .setHeader("X-RateLimit-Remaining-Month", "4321") + .setHeader("X-RateLimit-Limit-Minute", "30") .setBody("[]")); client.search("x", null); - verify(rotator).onQuota("KEY_A", 28, 4321); + verify(rotator).onCall("KEY_A"); + verify(rotator).onQuota("KEY_A", 28, 4321, 30, null); } @Test diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/getter/steam/SteamRateLimiterTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/getter/steam/SteamRateLimiterTest.java index 5988c0e22..707a28c20 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/getter/steam/SteamRateLimiterTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/getter/steam/SteamRateLimiterTest.java @@ -24,4 +24,19 @@ void acquireBlocking_returnsFalse_whenInterrupted() throws InterruptedException t.join(1000); assertThat(result.get()).isFalse(); } + + @Test + void aKeysUsage_isItsPermitsTakenThisWindow_andItsCallsOfTheDay() { + SteamRateLimiter limiter = new SteamRateLimiter(3, 50, 5000, new io.micrometer.core.instrument.simple.SimpleMeterRegistry()); + limiter.setDailyLimit(1000); + assertThat(limiter.usage("K")).isEqualTo(new fr.enimaloc.catapult.common.dto.admin.KeyUsageDto( + 0, 3, 5, null, "DAY", 0, 1000L, null)); + + assertThat(limiter.acquire("K")).isTrue(); + assertThat(limiter.acquireBlocking("K")).isTrue(); + + var usage = limiter.usage("K"); + assertThat(usage.windowCalls()).isEqualTo(2); + assertThat(usage.periodCalls()).isEqualTo(2); + } } diff --git a/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/ApiKeysPageDto.java b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/ApiKeysPageDto.java index d5b0c911e..367e99990 100644 --- a/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/ApiKeysPageDto.java +++ b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/ApiKeysPageDto.java @@ -6,11 +6,18 @@ /** * A provider's shared API keys as the admin Keys tab shows them: whether the integration is enabled * and each key by its id and masked value only (the raw key never leaves catapult-api), its owner - * (null for an admin's) and any remaining rate-limit block. Built from a provider's page: none + * (null for an admin's), any remaining rate-limit block and what it was used for. Built from a provider's page: none * (catapult-api unreachable) gives null, a page without its key list no keys. */ public record ApiKeysPageDto(boolean enabled, List keys) { - public record Key(String id, String masked, String owner, boolean blocked, long blockedForSeconds) {} + public record Key(String id, String masked, String owner, boolean blocked, long blockedForSeconds, + KeyUsageDto usage) { + + /** A key without its usage. */ + public Key(String id, String masked, String owner, boolean blocked, long blockedForSeconds) { + this(id, masked, owner, blocked, blockedForSeconds, null); + } + } public ApiKeysPageDto { keys = keys == null ? List.of() : keys; @@ -27,7 +34,7 @@ public static ApiKeysPageDto of(DtddKeysPageData page) { private static List keys(List keys) { return keys == null ? List.of() : keys.stream() .filter(Objects::nonNull) - .map(k -> new Key(k.id(), k.masked(), k.owner(), k.blocked(), k.blockedForSeconds())) + .map(k -> new Key(k.id(), k.masked(), k.owner(), k.blocked(), k.blockedForSeconds(), k.usage())) .toList(); } } diff --git a/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/KeyStatus.java b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/KeyStatus.java index 9105e8f99..6ce1feb83 100644 --- a/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/KeyStatus.java +++ b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/KeyStatus.java @@ -1,3 +1,11 @@ package fr.enimaloc.catapult.common.dto.admin; -public record KeyStatus(String id, String masked, String owner, boolean blocked, long blockedForSeconds) {} +/** A shared API key: its id, masked value, owner, remaining rate-limit block, and what it was used for (or null). */ +public record KeyStatus(String id, String masked, String owner, boolean blocked, long blockedForSeconds, + KeyUsageDto usage) { + + /** A key without its usage. */ + public KeyStatus(String id, String masked, String owner, boolean blocked, long blockedForSeconds) { + this(id, masked, owner, blocked, blockedForSeconds, null); + } +} diff --git a/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/KeyUsageDto.java b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/KeyUsageDto.java new file mode 100644 index 000000000..648c5933c --- /dev/null +++ b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/KeyUsageDto.java @@ -0,0 +1,10 @@ +package fr.enimaloc.catapult.common.dto.admin; + +/** + * What a shared API key was used for, as the admin Keys tab shows it live: the calls Catapult sent with it in the + * provider's short window ({@code windowSeconds}: Steam's 5 s, DTDD's minute) out of {@code windowLimit}, and in its + * longer {@code period} (DAY: Steam's daily limit, MONTH: DTDD's) out of {@code periodLimit}; {@code *Remaining}: + * what the provider said was left, null when it doesn't say. Counted by the catapult-api instance that answered. + */ +public record KeyUsageDto(int windowCalls, Integer windowLimit, int windowSeconds, Integer windowRemaining, + String period, long periodCalls, Long periodLimit, Integer periodRemaining) {} diff --git a/catapult-common/src/test/java/fr/enimaloc/catapult/common/dto/AdminDtosTest.java b/catapult-common/src/test/java/fr/enimaloc/catapult/common/dto/AdminDtosTest.java index fc40e044c..ca27e8c4d 100644 --- a/catapult-common/src/test/java/fr/enimaloc/catapult/common/dto/AdminDtosTest.java +++ b/catapult-common/src/test/java/fr/enimaloc/catapult/common/dto/AdminDtosTest.java @@ -72,4 +72,18 @@ void aRecalculation_countsWhatItWentThrough() { assertThat(TwRebuildStatusDto.IDLE.done()).isZero(); assertThat(TwRebuildStatusDto.IDLE.state()).isEqualTo("IDLE"); } + + @Test + void aKeysUsage_travelsFromItsStatusToThePage() { + var usage = new fr.enimaloc.catapult.common.dto.admin.KeyUsageDto(2, 3, 5, null, "DAY", 40, 100_000L, null); + var status = new fr.enimaloc.catapult.common.dto.admin.KeyStatus("id", "ab…cd", null, false, 0, usage); + var page = fr.enimaloc.catapult.common.dto.admin.ApiKeysPageDto.of( + new fr.enimaloc.catapult.common.dto.admin.SteamKeysPageData(java.util.List.of(status), true)); + + org.assertj.core.api.Assertions.assertThat(page.keys().getFirst().usage()).isEqualTo(usage); + org.assertj.core.api.Assertions.assertThat(new fr.enimaloc.catapult.common.dto.admin.ApiKeysPageDto.Key( + "id", "ab…cd", null, false, 0).usage()).isNull(); + org.assertj.core.api.Assertions.assertThat(new fr.enimaloc.catapult.common.dto.admin.KeyStatus( + "id", "ab…cd", null, false, 0).usage()).isNull(); + } } From d5119995dc7338f45015dd59460f64e9a1600233 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 16:53:28 +0100 Subject: [PATCH 2/2] feat(admin): merge the rate limits into the Keys tab, with each key's usage, updated live --- .../AdminTabsFragmentController.java | 11 +---- .../catapult/service/mock/MockKeysStore.java | 13 +++++- .../main/resources/lang/messages.properties | 7 +++- .../resources/lang/messages_en.properties | 7 +++- .../resources/lang/messages_fr.properties | 7 +++- .../main/resources/static/css/catapult.css | 21 ++++++---- .../resources/static/js/admin-rate-limits.js | 42 ++++++++++++++++--- .../main/resources/static/js/channel-page.js | 8 +++- .../templates/pages/admin-tab/keys.html | 19 ++++++++- .../pages/admin-tab/rate-limits.html | 11 +++-- .../main/resources/templates/pages/admin.html | 8 ++-- .../AdminRateLimitsRenderingTest.java | 10 +++-- .../AdminTabsFragmentControllerTest.java | 21 ++++++++++ .../src/test/js/admin-rate-limits.test.js | 35 +++++++++++++++- catapult-web/src/test/js/channel-page.test.js | 11 +++++ 15 files changed, 189 insertions(+), 42 deletions(-) diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminTabsFragmentController.java b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminTabsFragmentController.java index 6dd38f9fc..9dcf8d06f 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminTabsFragmentController.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminTabsFragmentController.java @@ -412,15 +412,6 @@ String dtddCacheEntry(Model model, HttpServletResponse response, @PathVariable S }); } - /** The rate limits learned from the platforms' 429s: each host, then its routes. */ - @GetMapping("/rate-limits") - String rateLimits(Model model, HttpServletResponse response) { - return guarded(model, response, () -> { - model.addAttribute("limits", apiService.adminRateLimits()); - return "pages/admin-tab/rate-limits :: list"; - }); - } - @GetMapping("/groups") String groups(Model model, HttpServletResponse response) { return guarded(model, response, () -> { @@ -484,6 +475,8 @@ String keys(Model model, HttpServletResponse response) { model.addAttribute("steam", apiService.adminApiKeys(ApiKeyProvider.STEAM)); model.addAttribute("dtdd", apiService.adminApiKeys(ApiKeyProvider.DTDD)); model.addAttribute("minecraft", apiService.adminMinecraftAccounts()); + // The rate limits learned from the platforms' 429s: each host, then its routes + model.addAttribute("limits", apiService.adminRateLimits()); return "pages/admin-tab/keys :: content"; }); } diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockKeysStore.java b/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockKeysStore.java index 904e76b32..93b6e9e68 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockKeysStore.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockKeysStore.java @@ -2,6 +2,7 @@ import fr.enimaloc.catapult.common.dto.admin.ApiKeyProvider; import fr.enimaloc.catapult.common.dto.admin.ApiKeysPageDto; +import fr.enimaloc.catapult.common.dto.admin.KeyUsageDto; import java.util.ArrayList; import java.util.EnumMap; @@ -35,9 +36,19 @@ private static String id(String key) { return Integer.toHexString(key.hashCode()); } + /** A key's usage moving with the clock, so that the tab's live update shows. */ + private static KeyUsageDto usage(ApiKeyProvider provider) { + long tick = System.currentTimeMillis() / 3000; + int window = (int) (tick % 4); + return provider == ApiKeyProvider.STEAM + ? new KeyUsageDto(window % 3, 3, 5, null, "DAY", 1200 + tick % 100, 100_000L, null) + : new KeyUsageDto(window, 30, 60, 30 - window, "MONTH", 120 + tick % 50, 5000L, + (int) (4880 - tick % 50)); + } + synchronized ApiKeysPageDto page(ApiKeyProvider provider) { return new ApiKeysPageDto(true, keys.get(provider).stream() - .map(k -> new ApiKeysPageDto.Key(id(k), mask(k), null, false, 0L)).toList()); + .map(k -> new ApiKeysPageDto.Key(id(k), mask(k), null, false, 0L, usage(provider))).toList()); } synchronized int add(ApiKeyProvider provider, String apiKey) { diff --git a/catapult-web/src/main/resources/lang/messages.properties b/catapult-web/src/main/resources/lang/messages.properties index bf4e4d307..cd0465355 100644 --- a/catapult-web/src/main/resources/lang/messages.properties +++ b/catapult-web/src/main/resources/lang/messages.properties @@ -960,7 +960,7 @@ join.no_code=No valid invite code provided. join.error.invalid_invite=This invite code is invalid or has been exhausted. join.error.alpha_full=The alpha is currently full. -admin.tab.keys=Keys +admin.tab.keys=Keys & limits admin.keys.unknown=This key no longer exists. admin.keys.unavailable=The keys are unavailable right now. admin.keys.added=Key added. @@ -971,6 +971,11 @@ admin.keys.add.button=Add admin.keys.column.key=API key admin.keys.column.owner=Owner admin.keys.column.status=Status +admin.keys.column.usage=Usage +admin.keys.usage.window={0} / {1} in {2} s +admin.keys.usage.remaining=({0} left) +admin.keys.usage.period.DAY={0} / {1} today +admin.keys.usage.period.MONTH={0} / {1} this month admin.keys.owner.admin=Admin admin.keys.status.available=Available admin.keys.status.blocked=Blocked — {0} diff --git a/catapult-web/src/main/resources/lang/messages_en.properties b/catapult-web/src/main/resources/lang/messages_en.properties index 21c611b9a..a8f300f09 100644 --- a/catapult-web/src/main/resources/lang/messages_en.properties +++ b/catapult-web/src/main/resources/lang/messages_en.properties @@ -960,7 +960,7 @@ join.no_code=No valid invite code provided. join.error.invalid_invite=This invite code is invalid or has been exhausted. join.error.alpha_full=The alpha is currently full. -admin.tab.keys=Keys +admin.tab.keys=Keys & limits admin.keys.unknown=This key no longer exists. admin.keys.unavailable=The keys are unavailable right now. admin.keys.added=Key added. @@ -971,6 +971,11 @@ admin.keys.add.button=Add admin.keys.column.key=API key admin.keys.column.owner=Owner admin.keys.column.status=Status +admin.keys.column.usage=Usage +admin.keys.usage.window={0} / {1} in {2} s +admin.keys.usage.remaining=({0} left) +admin.keys.usage.period.DAY={0} / {1} today +admin.keys.usage.period.MONTH={0} / {1} this month admin.keys.owner.admin=Admin admin.keys.status.available=Available admin.keys.status.blocked=Blocked — {0} diff --git a/catapult-web/src/main/resources/lang/messages_fr.properties b/catapult-web/src/main/resources/lang/messages_fr.properties index 0e48e67af..8c791df0b 100644 --- a/catapult-web/src/main/resources/lang/messages_fr.properties +++ b/catapult-web/src/main/resources/lang/messages_fr.properties @@ -960,7 +960,7 @@ join.no_code=Aucun code d'invitation valide fourni. join.error.invalid_invite=Ce code d'invitation est invalide ou a été épuisé. join.error.alpha_full=L'alpha est actuellement complète. -admin.tab.keys=Clés +admin.tab.keys=Clés & limites admin.keys.unknown=Cette clé n'existe plus. admin.keys.unavailable=Les clés sont indisponibles pour le moment. admin.keys.added=Clé ajoutée. @@ -971,6 +971,11 @@ admin.keys.add.button=Ajouter admin.keys.column.key=Clé API admin.keys.column.owner=Propriétaire admin.keys.column.status=Statut +admin.keys.column.usage=Utilisation +admin.keys.usage.window={0} / {1} en {2} s +admin.keys.usage.remaining=({0} restants) +admin.keys.usage.period.DAY={0} / {1} aujourd''hui +admin.keys.usage.period.MONTH={0} / {1} ce mois admin.keys.owner.admin=Admin admin.keys.status.available=Disponible admin.keys.status.blocked=Bloquée — {0} diff --git a/catapult-web/src/main/resources/static/css/catapult.css b/catapult-web/src/main/resources/static/css/catapult.css index 9e20e4cbd..da308c081 100644 --- a/catapult-web/src/main/resources/static/css/catapult.css +++ b/catapult-web/src/main/resources/static/css/catapult.css @@ -2097,10 +2097,10 @@ span.box-art { * The tables below are one grid each, every row (header included) a subgrid of it: as separate grids, * the rows sized their action column to their own buttons, so no cell sat under its header. */ -.admin-table:has(> :is(.admin-dtdd-row, .admin-groups-row, .admin-whitelist-invite-row, .admin-whitelist-row, .admin-keys-row, .admin-minecraft-row, .admin-tw-row, .admin-meta-tag-row, .admin-social-link-row, .invite-row, .admin-gm-row, .admin-gm-missing-row, .admin-dc-topics-row, .admin-dc-games-row, .admin-dc-searches-row, .admin-dc-quota-row)) { display: grid; column-gap: 12px; } -.admin-table:has(> :is(.admin-dtdd-row, .admin-groups-row, .admin-whitelist-invite-row, .admin-whitelist-row, .admin-keys-row, .admin-minecraft-row, .admin-tw-row, .admin-meta-tag-row, .admin-social-link-row, .invite-row, .admin-gm-row, .admin-gm-missing-row, .admin-dc-topics-row, .admin-dc-games-row, .admin-dc-searches-row, .admin-dc-quota-row)) > * { grid-column: 1 / -1; } -.admin-table > :is(.admin-dtdd-row, .admin-groups-row, .admin-whitelist-invite-row, .admin-whitelist-row, .admin-keys-row, .admin-minecraft-row, .admin-tw-row, .admin-meta-tag-row, .admin-social-link-row, .invite-row, .admin-gm-row, .admin-gm-missing-row, .admin-dc-topics-row, .admin-dc-games-row, .admin-dc-searches-row, .admin-dc-quota-row) { grid-template-columns: subgrid; } -.admin-table > :is(.admin-dtdd-row, .admin-groups-row, .admin-whitelist-invite-row, .admin-whitelist-row, .admin-keys-row, .admin-minecraft-row, .admin-tw-row, .admin-meta-tag-row, .admin-social-link-row, .invite-row, .admin-gm-row, .admin-gm-missing-row, .admin-dc-topics-row, .admin-dc-games-row, .admin-dc-searches-row, .admin-dc-quota-row) > .admin-row-actions { justify-content: flex-end; } +.admin-table:has(> :is(.admin-dtdd-row, .admin-groups-row, .admin-whitelist-invite-row, .admin-whitelist-row, .admin-keys-row, .admin-minecraft-row, .admin-tw-row, .admin-meta-tag-row, .admin-social-link-row, .invite-row, .admin-gm-row, .admin-gm-missing-row, .admin-dc-topics-row, .admin-dc-games-row, .admin-dc-searches-row, .admin-dc-quota-row, .admin-rl-row)) { display: grid; column-gap: 12px; } +.admin-table:has(> :is(.admin-dtdd-row, .admin-groups-row, .admin-whitelist-invite-row, .admin-whitelist-row, .admin-keys-row, .admin-minecraft-row, .admin-tw-row, .admin-meta-tag-row, .admin-social-link-row, .invite-row, .admin-gm-row, .admin-gm-missing-row, .admin-dc-topics-row, .admin-dc-games-row, .admin-dc-searches-row, .admin-dc-quota-row, .admin-rl-row)) > * { grid-column: 1 / -1; } +.admin-table > :is(.admin-dtdd-row, .admin-groups-row, .admin-whitelist-invite-row, .admin-whitelist-row, .admin-keys-row, .admin-minecraft-row, .admin-tw-row, .admin-meta-tag-row, .admin-social-link-row, .invite-row, .admin-gm-row, .admin-gm-missing-row, .admin-dc-topics-row, .admin-dc-games-row, .admin-dc-searches-row, .admin-dc-quota-row, .admin-rl-row) { grid-template-columns: subgrid; } +.admin-table > :is(.admin-dtdd-row, .admin-groups-row, .admin-whitelist-invite-row, .admin-whitelist-row, .admin-keys-row, .admin-minecraft-row, .admin-tw-row, .admin-meta-tag-row, .admin-social-link-row, .invite-row, .admin-gm-row, .admin-gm-missing-row, .admin-dc-topics-row, .admin-dc-games-row, .admin-dc-searches-row, .admin-dc-quota-row, .admin-rl-row) > .admin-row-actions { justify-content: flex-end; } .admin-pagination { display: flex; @@ -2409,8 +2409,8 @@ mdui-snackbar.broadcast-warning { --mdui-color-inverse-surface: var(--mdui-color .admin-dc-games-row, .admin-table:has(> .admin-dc-games-row), .admin-dc-searches-row, .admin-table:has(> .admin-dc-searches-row), .admin-dc-quota-row, .admin-table:has(> .admin-dc-quota-row) { grid-template-columns: minmax(0, 1fr); } - :is(.admin-dc-topics-row, .admin-dc-games-row, .admin-dc-searches-row, .admin-dc-quota-row) > [data-label]::before { content: attr(data-label) " : "; color: var(--catapult-text-muted); } - :is(.admin-dc-topics-row, .admin-dc-games-row, .admin-dc-searches-row, .admin-dc-quota-row) > .admin-cell { white-space: normal; } + :is(.admin-dc-topics-row, .admin-dc-games-row, .admin-dc-searches-row, .admin-dc-quota-row, .admin-rl-row) > [data-label]::before { content: attr(data-label) " : "; color: var(--catapult-text-muted); } + :is(.admin-dc-topics-row, .admin-dc-games-row, .admin-dc-searches-row, .admin-dc-quota-row, .admin-rl-row) > .admin-cell { white-space: normal; } } /* The admin Groups tab. */ @@ -2478,12 +2478,19 @@ mdui-snackbar.broadcast-warning { --mdui-color-inverse-surface: var(--mdui-color .admin-keys-head h3 { margin: 0; } .admin-keys-add { display: flex; gap: 12px; align-items: start; margin: 16px 0; } .admin-keys-add mdui-text-field { flex: 1; max-width: 24rem; } -.admin-keys-row, .admin-table:has(> .admin-keys-row) { grid-template-columns: minmax(0, 1fr) 10rem 10rem auto; } +.admin-keys-row, .admin-table:has(> .admin-keys-row) { grid-template-columns: minmax(0, 1fr) 10rem 10rem minmax(0, 1.4fr) auto; } +.admin-keys-usage { display: flex; flex-wrap: wrap; column-gap: 6px; } +.admin-rl-row, .admin-table:has(> .admin-rl-row) { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) 7rem minmax(0, 1fr) minmax(0, 1fr) auto; } +.admin-rl-row > .admin-cell { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; } +.admin-rl-blocked { color: rgb(var(--mdui-color-error)); } +.admin-rate-limits { margin-top: 24px; } .admin-keys-blocked:not([data-live-done]) { color: rgb(var(--mdui-color-error)); } @media (max-width: 600px) { .admin-keys-add { flex-direction: column; align-items: stretch; } .admin-keys-add mdui-text-field { max-width: none; } .admin-keys-row, .admin-table:has(> .admin-keys-row) { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } + .admin-rl-row, .admin-table:has(> .admin-rl-row) { grid-template-columns: minmax(0, 1fr); } + .admin-rl-row > [data-label]:not(:first-child)::before { content: attr(data-label) " : "; color: var(--catapult-text-muted); } .admin-keys-row > [data-label]::before { content: attr(data-label) " : "; color: var(--catapult-text-muted); } .admin-keys-row > .admin-row-actions { grid-column: 1 / -1; } .admin-keys-row > .admin-cell { white-space: normal; } diff --git a/catapult-web/src/main/resources/static/js/admin-rate-limits.js b/catapult-web/src/main/resources/static/js/admin-rate-limits.js index 61ab2e354..c70ad80f1 100644 --- a/catapult-web/src/main/resources/static/js/admin-rate-limits.js +++ b/catapult-web/src/main/resources/static/js/admin-rate-limits.js @@ -1,12 +1,16 @@ /** - * The admin rate limits tab. A learned limit is corrected in the view's dialog, filled from its row (an empty field - * leaves that part to the learning; a host has no scope to choose), or reset once confirmed; the list is then patched - * (admin-tabs.js). A refusal is told with the view's data-error-{status}, else its data-error ({0}: the status). - * Listeners are delegated to the panel: its content comes and goes. + * The learned rate limits, in the admin Keys tab. A learned limit is corrected in the view's dialog, filled from its + * row (an empty field leaves that part to the learning; a host has no scope to choose), or reset once confirmed; the + * tab is then patched (admin-tabs.js). A refusal is told with the view's data-error-{status}, else its data-error + * ({0}: the status). While the tab is shown, it is patched every few seconds (the keys' usage, the limits), unless a + * dialog is open or the page hidden. Listeners are delegated to the panel: its content comes and goes. */ window.CatapultAdminRateLimits = (function () { - const TAB = "rate-limits"; + const TAB = "keys"; const BASE = "/admin/rate-limits"; + const POLL_MS = 3000; + + let timer = null; const panelOf = () => document.querySelector(`mdui-tab-panel[value="${TAB}"]`); @@ -63,10 +67,32 @@ window.CatapultAdminRateLimits = (function () { await CatapultAdminTabs.patch(panel); } + /** Whether the tab is shown, its view loaded, and nothing being edited in a dialog. */ + function live(panel) { + return panel.isConnected && document.getElementById("admin-tabs")?.value === TAB + && document.visibilityState !== "hidden" && !!panel.querySelector(".admin-rate-limits") + && !panel.querySelector("mdui-dialog[open]"); + } + + /** Patches the tab every POLL_MS while it is shown; stops by itself when it no longer is. */ + function follow(panel) { + clearTimeout(timer); + timer = null; + if (!panel?.isConnected || document.getElementById("admin-tabs")?.value !== TAB) return; + timer = setTimeout(async () => { + if (live(panel)) await CatapultAdminTabs.patch(panel); + follow(panel); + }, POLL_MS); + } + function attach() { const panel = panelOf(); if (!panel || panel.dataset.rateLimitsWired) return; panel.dataset.rateLimitsWired = "true"; + // Back on the tab: followed again (left, it stopped being) + document.getElementById("admin-tabs")?.addEventListener("change", event => { + if (event.target.id === "admin-tabs") follow(panel); + }); panel.addEventListener("click", event => { const button = event.target.closest(".admin-rl-edit, .admin-rl-reset, .admin-rl-save, .admin-dialog-close"); @@ -81,6 +107,10 @@ window.CatapultAdminRateLimits = (function () { } document.addEventListener("catapult:render", attach); + // The tab's view shown: followed + document.addEventListener("catapult:admin-panel", event => { + if (event.detail?.tab === TAB) follow(event.detail.panel); + }); - return { attach, correctionOf }; + return { attach, correctionOf, follow }; })(); diff --git a/catapult-web/src/main/resources/static/js/channel-page.js b/catapult-web/src/main/resources/static/js/channel-page.js index 853b74586..1f2e40ac1 100644 --- a/catapult-web/src/main/resources/static/js/channel-page.js +++ b/catapult-web/src/main/resources/static/js/channel-page.js @@ -161,8 +161,12 @@ document.addEventListener("catapult:render", function () { if (!tabs) return; const first = tabs.value; - // "#caches/igdb-game-cache": the tab, then the sub-view admin-tabs.js opens in it. - const wanted = location.hash.slice(1).split("/")[0]; + // "#caches/igdb-game-cache": the tab, then the sub-view admin-tabs.js opens in it. A former tab's hash + // (data-hash-aliases) opens the tab it went into, and the hash then names that one. + const named = location.hash.slice(1).split("/")[0]; + const aliases = JSON.parse(tabs.dataset.hashAliases || "{}"); + const wanted = Object.hasOwn(aliases, named) ? aliases[named] : named; + if (wanted !== named) history.replaceState(history.state, "", location.pathname + location.search + `#${wanted}`); // Non-owners only get the overview tab: ignore a hash naming one they don't have. if (wanted && tabs.querySelector(`mdui-tab[value="${CSS.escape(wanted)}"]`)) { tabs.value = wanted; diff --git a/catapult-web/src/main/resources/templates/pages/admin-tab/keys.html b/catapult-web/src/main/resources/templates/pages/admin-tab/keys.html index 0ed3a4845..5a474e780 100644 --- a/catapult-web/src/main/resources/templates/pages/admin-tab/keys.html +++ b/catapult-web/src/main/resources/templates/pages/admin-tab/keys.html @@ -3,8 +3,9 @@
Keys Key Owner Status + Usage
@@ -60,6 +62,17 @@

Keys

data-label-done=#{admin.keys.status.available}" th:text="#{admin.keys.status.blocked(${key.blockedForSeconds() + 's'})}">Blocked + + — + + 2 / 3 in 5 s + left + 40 / 100000 today + left + + Minecraft service accounts
+ + diff --git a/catapult-web/src/main/resources/templates/pages/admin-tab/rate-limits.html b/catapult-web/src/main/resources/templates/pages/admin-tab/rate-limits.html index b295a24ca..eedd92561 100644 --- a/catapult-web/src/main/resources/templates/pages/admin-tab/rate-limits.html +++ b/catapult-web/src/main/resources/templates/pages/admin-tab/rate-limits.html @@ -2,7 +2,8 @@
Rate limits

hint

- + +
- - +
+ - - + + + admin.tab -
diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminRateLimitsRenderingTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminRateLimitsRenderingTest.java index 57f1b2d79..cc57698f2 100644 --- a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminRateLimitsRenderingTest.java +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminRateLimitsRenderingTest.java @@ -23,7 +23,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -/** The admin rate limits tab as it renders: each host, then its routes, with what was learned or corrected. */ +/** The learned rate limits as the admin Keys tab renders them: each host, then its routes, learned or corrected. */ @WebMvcTest(controllers = AdminTabsFragmentController.class) @Import({ModelFiller.class, WebSecurityConfig.class, TwitchatCatalogConfig.class, I18nConfiguration.class}) class AdminRateLimitsRenderingTest { @@ -41,7 +41,7 @@ void anAdmin() { } private String render(Locale locale) throws Exception { - return mvc.perform(get("/spa/admin/rate-limits").locale(locale).sessionAttr(ApiClient.SESSION_JWT_KEY, "jwt")) + return mvc.perform(get("/spa/admin/keys").locale(locale).sessionAttr(ApiClient.SESSION_JWT_KEY, "jwt")) .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); } @@ -69,7 +69,11 @@ void eachHost_thenItsRoutes_withWhatWasLearnedOrCorrected() throws Exception { @Test void nothingLearned_orCatapultApiDown_isTold() throws Exception { when(apiService.adminRateLimits()).thenReturn(List.of()); - assertThat(render(Locale.FRENCH)).contains("Aucune plateforme n'a encore limité Catapult."); + String none = render(Locale.FRENCH); + assertThat(none).contains("Aucune plateforme n'a encore limité Catapult.", "data-admin-value=\"limits-empty\""); + // the (empty) list is rendered hidden, so that a patch shows the first limit learned + String list = none.substring(none.indexOf("data-admin-list=\"limits\"")); + assertThat(list.substring(0, list.indexOf('>'))).contains("hidden"); when(apiService.adminRateLimits()).thenReturn(null); assertThat(render(Locale.FRENCH)).doesNotContain("admin-rl-row\""); diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminTabsFragmentControllerTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminTabsFragmentControllerTest.java index 513ce8ca1..98a112e94 100644 --- a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminTabsFragmentControllerTest.java +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminTabsFragmentControllerTest.java @@ -931,6 +931,27 @@ void keys_areShownMasked_neverRaw() throws Exception { .doesNotContain("RAWKEY123"); } + @Test + void eachKey_tellsItsUsage_andTheLearnedLimitsFollowTheKeys() throws Exception { + var steam = new fr.enimaloc.catapult.common.dto.admin.ApiKeysPageDto.Key("id-1", "abcd…wxyz", null, false, 0, + new fr.enimaloc.catapult.common.dto.admin.KeyUsageDto(2, 3, 5, null, "DAY", 1234, 100_000L, null)); + var dtdd = new fr.enimaloc.catapult.common.dto.admin.ApiKeysPageDto.Key("id-2", "ddd_…mnop", null, false, 0, + new fr.enimaloc.catapult.common.dto.admin.KeyUsageDto(4, 30, 60, 26, "MONTH", 120, 5000L, 4880)); + var bare = new fr.enimaloc.catapult.common.dto.admin.ApiKeysPageDto.Key("id-3", "efgh…ijkl", null, false, 0); + when(apiService.adminApiKeys(fr.enimaloc.catapult.common.dto.admin.ApiKeyProvider.STEAM)).thenReturn(keys(true, steam, bare)); + when(apiService.adminApiKeys(fr.enimaloc.catapult.common.dto.admin.ApiKeyProvider.DTDD)).thenReturn(keys(true, dtdd)); + when(apiService.adminRateLimits()).thenReturn(List.of(new fr.enimaloc.catapult.common.dto.admin.LearnedRateLimitDto( + "HOST store.steampowered.com", "store.steampowered.com", null, null, null, false, 190, 300, false, + 60_000, false, 1, null, 3, null))); + + String html = asAdmin("/spa/admin/keys"); + + assertThat(html).contains("2 / 3 in 5 s", "1,234 / 100,000 today", "4 / 30 in 60 s", "(26 left)", + "120 / 5,000 this month", "(4,880 left)", "admin-rate-limits", "data-admin-list=\"limits\"", + "190 per 5 min"); + assertThat(html.indexOf("admin-rate-limits")).isGreaterThan(html.indexOf("id=\"admin-minecraft\"")); + } + @Test void unreachableApi_showsThePlaceholder() throws Exception { when(apiService.adminApiKeys(org.mockito.ArgumentMatchers.any())).thenReturn(null); diff --git a/catapult-web/src/test/js/admin-rate-limits.test.js b/catapult-web/src/test/js/admin-rate-limits.test.js index 868df9adf..a666bb096 100644 --- a/catapult-web/src/test/js/admin-rate-limits.test.js +++ b/catapult-web/src/test/js/admin-rate-limits.test.js @@ -11,7 +11,8 @@ let confirmAnswer; function list() { html(` - + +
@@ -32,10 +33,11 @@ function list() {
`); + document.getElementById("admin-tabs").value = "keys"; render(); } -const panel = () => document.querySelector('mdui-tab-panel[value="rate-limits"]'); +const panel = () => document.querySelector('mdui-tab-panel[value="keys"]'); const dialog = () => document.getElementById("admin-rl-dialog"); const field = name => dialog().querySelector(`[name="${name}"]`); const tick = () => new Promise(resolve => setTimeout(resolve, 0)); @@ -133,4 +135,33 @@ describe("admin-rate-limits", () => { await click("#reset-host"); expect(tabs.patch).toHaveBeenCalledTimes(2); }); + + it("patches the tab every few seconds while it is shown, not while a dialog is open", async () => { + vi.useFakeTimers(); + try { + list(); + document.dispatchEvent(new CustomEvent("catapult:admin-panel", { detail: { tab: "keys", panel: panel() } })); + await vi.advanceTimersByTimeAsync(3000); + expect(tabs.patch).toHaveBeenCalledTimes(1); + + dialog().setAttribute("open", ""); + await vi.advanceTimersByTimeAsync(3000); + expect(tabs.patch).toHaveBeenCalledTimes(1); + dialog().removeAttribute("open"); + + document.getElementById("admin-tabs").value = "tw"; + await vi.advanceTimersByTimeAsync(9000); + expect(tabs.patch).toHaveBeenCalledTimes(1); + + document.getElementById("admin-tabs").value = "keys"; + document.getElementById("admin-tabs").dispatchEvent(new Event("change")); + await vi.advanceTimersByTimeAsync(3000); + expect(tabs.patch).toHaveBeenCalledTimes(2); + + // an event for another tab changes nothing + document.dispatchEvent(new CustomEvent("catapult:admin-panel", { detail: { tab: "tw", panel: panel() } })); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/catapult-web/src/test/js/channel-page.test.js b/catapult-web/src/test/js/channel-page.test.js index cb4630bc9..c5757fbea 100644 --- a/catapult-web/src/test/js/channel-page.test.js +++ b/catapult-web/src/test/js/channel-page.test.js @@ -248,6 +248,17 @@ describe("tab <-> location.hash", () => { expect(location.pathname + location.hash).toBe("/admin"); }); + it("opens the tab a former tab's hash went into, and names it in the hash", () => { + history.replaceState(null, "", "/admin#rate-limits"); + html(``); + const el = document.querySelector("mdui-tabs"); + el.value = "members"; + render(); + + expect(el.value).toBe("keys"); + expect(location.hash).toBe("#keys"); + }); + it("selects the tab of a hash naming a sub-view", () => { history.replaceState(null, "", "/admin#caches/igdb-game-cache"); html(``);