From af38411d0c8aed5dea627cca98119423ffac1741 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 27 Jul 2026 14:29:35 +0200 Subject: [PATCH 1/8] feat(api+frontend/admin): per-API-key usage stats (closes #340/#344 scope) Revives the deferred "API keys usage stats" sub-task from #340/#344 (prior PR #380 closed stale). Fresh implementation against current main using #380's body as spec. Backend: migration 000094 adds request_count_total/request_count_24h counters to api_keys, plus an atomic RecordAPIKeyUsage store method that increments both alongside last_used_at with a rolling 24h window. New GET /api/api-keys/usage-stats aggregates the calling user's own keys into a section summary (active count, 24h/lifetime totals, top-3 most active). OpenAPI updated. Frontend: Admin -> API Keys now shows a 3-tile summary card (active keys, requests 24h, requests lifetime) + a top-3 most-active list, plus per-row Requests (24h) / Requests (total) columns. Loading skeletons via lib/skeleton; summary errors stay isolated from list errors. Migration numbered 000094 (not #380's stale 000051) since 000093 is already claimed by in-flight PR #1516. --- frontend/src/__tests__/api-apikeys.test.ts | 41 +++++ frontend/src/__tests__/apikeys.test.ts | 139 +++++++++++++++ frontend/src/api/apikeys.ts | 16 +- frontend/src/api/index.ts | 3 + frontend/src/api/types.ts | 20 +++ frontend/src/apikeys.ts | 164 ++++++++++++++++++ frontend/src/index.html | 3 + frontend/src/styles/components.css | 67 +++++++ frontend/src/types.ts | 6 + internal/api/handler_apikeys.go | 21 +++ internal/api/handler_apikeys_test.go | 87 ++++++++++ internal/api/handler_ri_exchange_test.go | 3 + internal/api/mocks_test.go | 5 + internal/api/openapi.yaml | 64 +++++++ internal/api/router.go | 9 +- internal/api/types.go | 4 + internal/auth/interfaces.go | 5 + internal/auth/service_apikeys.go | 29 +++- internal/auth/service_apikeys_api.go | 128 ++++++++++++-- internal/auth/service_apikeys_api_test.go | 127 +++++++++++++- internal/auth/service_apikeys_test.go | 46 ++++- internal/auth/service_security_test.go | 8 +- internal/auth/store_postgres.go | 57 +++++- internal/auth/store_postgres_pgxmock_test.go | 47 +++++ internal/auth/test_helpers.go | 5 + internal/auth/types.go | 6 + .../000094_api_keys_usage_counters.down.sql | 5 + .../000094_api_keys_usage_counters.up.sql | 25 +++ internal/mocks/stores.go | 6 + internal/server/app.go | 4 + internal/server/health_test.go | 4 + 31 files changed, 1113 insertions(+), 41 deletions(-) create mode 100644 internal/database/postgres/migrations/000094_api_keys_usage_counters.down.sql create mode 100644 internal/database/postgres/migrations/000094_api_keys_usage_counters.up.sql diff --git a/frontend/src/__tests__/api-apikeys.test.ts b/frontend/src/__tests__/api-apikeys.test.ts index e54471c1d..960d2752d 100644 --- a/frontend/src/__tests__/api-apikeys.test.ts +++ b/frontend/src/__tests__/api-apikeys.test.ts @@ -5,6 +5,7 @@ import { fetchMock } from './setup'; import { getApiKeys, + getApiKeysUsageStats, createApiKey, revokeApiKey, deleteApiKey @@ -56,6 +57,46 @@ describe('API Keys API Module', () => { }); }); + describe('getApiKeysUsageStats', () => { + test('fetches section-level usage summary', async () => { + const mockResponse = { + total_active: 2, + total_requests_24h: 42, + total_requests_lifetime: 1234, + top_keys: [ + { id: 'key-1', name: 'Busy', key_prefix: 'abc12345', request_count_24h: 30 }, + ], + }; + + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + const result = await getApiKeysUsageStats(); + + expect(fetchMock).toHaveBeenCalledWith( + '/api/api-keys/usage-stats', + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Authorization': 'Bearer test-token', + }), + }) + ); + expect(result).toEqual(mockResponse); + }); + + test('throws error on API failure', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 401, + json: () => Promise.resolve({ error: 'Unauthorized' }), + }); + + await expect(getApiKeysUsageStats()).rejects.toThrow('Unauthorized'); + }); + }); + describe('createApiKey', () => { test('creates API key with name only', async () => { const mockResponse = { diff --git a/frontend/src/__tests__/apikeys.test.ts b/frontend/src/__tests__/apikeys.test.ts index 3cc12f54b..ec3b94401 100644 --- a/frontend/src/__tests__/apikeys.test.ts +++ b/frontend/src/__tests__/apikeys.test.ts @@ -17,6 +17,7 @@ import { // Mock the api module jest.mock('../api', () => ({ getApiKeys: jest.fn(), + getApiKeysUsageStats: jest.fn(), createApiKey: jest.fn(), revokeApiKey: jest.fn(), deleteApiKey: jest.fn() @@ -36,6 +37,7 @@ describe('API Keys Module', () => { beforeEach(() => { // Reset DOM document.body.innerHTML = ` +