Skip to content

Commit 1287422

Browse files
committed
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.
1 parent b90b420 commit 1287422

31 files changed

Lines changed: 1113 additions & 41 deletions

‎frontend/src/__tests__/api-apikeys.test.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { fetchMock } from './setup';
66
import {
77
getApiKeys,
8+
getApiKeysUsageStats,
89
createApiKey,
910
revokeApiKey,
1011
deleteApiKey
@@ -56,6 +57,46 @@ describe('API Keys API Module', () => {
5657
});
5758
});
5859

60+
describe('getApiKeysUsageStats', () => {
61+
test('fetches section-level usage summary', async () => {
62+
const mockResponse = {
63+
total_active: 2,
64+
total_requests_24h: 42,
65+
total_requests_lifetime: 1234,
66+
top_keys: [
67+
{ id: 'key-1', name: 'Busy', key_prefix: 'abc12345', request_count_24h: 30 },
68+
],
69+
};
70+
71+
fetchMock.mockResolvedValue({
72+
ok: true,
73+
json: () => Promise.resolve(mockResponse),
74+
});
75+
76+
const result = await getApiKeysUsageStats();
77+
78+
expect(fetchMock).toHaveBeenCalledWith(
79+
'/api/api-keys/usage-stats',
80+
expect.objectContaining({
81+
headers: expect.objectContaining({
82+
'X-Authorization': 'Bearer test-token',
83+
}),
84+
})
85+
);
86+
expect(result).toEqual(mockResponse);
87+
});
88+
89+
test('throws error on API failure', async () => {
90+
fetchMock.mockResolvedValue({
91+
ok: false,
92+
status: 401,
93+
json: () => Promise.resolve({ error: 'Unauthorized' }),
94+
});
95+
96+
await expect(getApiKeysUsageStats()).rejects.toThrow('Unauthorized');
97+
});
98+
});
99+
59100
describe('createApiKey', () => {
60101
test('creates API key with name only', async () => {
61102
const mockResponse = {

‎frontend/src/__tests__/apikeys.test.ts‎

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
// Mock the api module
1818
jest.mock('../api', () => ({
1919
getApiKeys: jest.fn(),
20+
getApiKeysUsageStats: jest.fn(),
2021
createApiKey: jest.fn(),
2122
revokeApiKey: jest.fn(),
2223
deleteApiKey: jest.fn()
@@ -36,6 +37,7 @@ describe('API Keys Module', () => {
3637
beforeEach(() => {
3738
// Reset DOM
3839
document.body.innerHTML = `
40+
<div id="apikeys-usage-summary"></div>
3941
<div id="apikeys-list"></div>
4042
<button id="create-apikey-btn"></button>
4143
<div id="create-apikey-modal" class="hidden">
@@ -59,6 +61,15 @@ describe('API Keys Module', () => {
5961
// clears call history, not the mockResolvedValueOnce queue.
6062
mockConfirmDialog.mockReset();
6163
mockConfirmDialog.mockImplementation(() => Promise.resolve(true));
64+
// Default the usage-stats endpoint to an empty response so the
65+
// parallel-load path in loadApiKeys doesn't reject in tests that
66+
// only set up `getApiKeys`. Individual tests can override.
67+
(api.getApiKeysUsageStats as jest.Mock).mockResolvedValue({
68+
total_active: 0,
69+
total_requests_24h: 0,
70+
total_requests_lifetime: 0,
71+
top_keys: [],
72+
});
6273
});
6374

6475
describe('loadApiKeys', () => {
@@ -1000,4 +1011,132 @@ describe('API Keys Module', () => {
10001011
expect(span?.getAttribute('title')).toBe(new Date(recentTs).toISOString());
10011012
});
10021013
});
1014+
1015+
// Usage stats (issue #340/#344 deferred sub-task) — section-level summary
1016+
// card above the table + per-row request count columns.
1017+
describe('Usage stats', () => {
1018+
test('renders summary tiles when stats load successfully', async () => {
1019+
(api.getApiKeys as jest.Mock).mockResolvedValue({ api_keys: [] });
1020+
(api.getApiKeysUsageStats as jest.Mock).mockResolvedValue({
1021+
total_active: 2,
1022+
total_requests_24h: 42,
1023+
total_requests_lifetime: 1234,
1024+
top_keys: [
1025+
{ id: 'key-1', name: 'Busy', key_prefix: 'aaaa1111', request_count_24h: 30 },
1026+
{ id: 'key-2', name: 'Medium', key_prefix: 'bbbb2222', request_count_24h: 12 },
1027+
],
1028+
});
1029+
1030+
await loadApiKeys();
1031+
// Allow the parallel stats fetch to resolve.
1032+
await new Promise((r) => setTimeout(r, 0));
1033+
1034+
const summary = document.getElementById('apikeys-usage-summary');
1035+
expect(summary?.textContent).toContain('Active keys');
1036+
expect(summary?.textContent).toContain('2');
1037+
expect(summary?.textContent).toContain('Requests (24h)');
1038+
expect(summary?.textContent).toContain('42');
1039+
expect(summary?.textContent).toContain('Requests (lifetime)');
1040+
// formatCount abbreviates large numbers in the summary tiles to
1041+
// fit the tile width — table cells keep the exact integer.
1042+
expect(summary?.textContent).toContain('1.2k');
1043+
expect(summary?.textContent).toContain('Most active (24h)');
1044+
expect(summary?.textContent).toContain('Busy');
1045+
expect(summary?.textContent).toContain('aaaa1111');
1046+
});
1047+
1048+
test('omits top-list when no key has 24h activity', async () => {
1049+
(api.getApiKeys as jest.Mock).mockResolvedValue({ api_keys: [] });
1050+
(api.getApiKeysUsageStats as jest.Mock).mockResolvedValue({
1051+
total_active: 1,
1052+
total_requests_24h: 0,
1053+
total_requests_lifetime: 7,
1054+
top_keys: [],
1055+
});
1056+
1057+
await loadApiKeys();
1058+
await new Promise((r) => setTimeout(r, 0));
1059+
1060+
const summary = document.getElementById('apikeys-usage-summary');
1061+
expect(summary?.textContent).not.toContain('Most active (24h)');
1062+
});
1063+
1064+
test('shows inline error when stats endpoint fails, table still renders', async () => {
1065+
(api.getApiKeys as jest.Mock).mockResolvedValue({
1066+
api_keys: [
1067+
{ id: 'key-1', name: 'k', key_prefix: 'abc12345', is_active: true, created_at: '2024-01-15T10:00:00Z' },
1068+
],
1069+
});
1070+
(api.getApiKeysUsageStats as jest.Mock).mockRejectedValue(new Error('boom'));
1071+
1072+
await loadApiKeys();
1073+
await new Promise((r) => setTimeout(r, 0));
1074+
1075+
const summary = document.getElementById('apikeys-usage-summary');
1076+
expect(summary?.querySelector('.error')?.textContent).toBe('Failed to load usage summary');
1077+
// The table itself still renders despite the summary failure.
1078+
const list = document.getElementById('apikeys-list');
1079+
expect(list?.innerHTML).toContain('abc12345');
1080+
});
1081+
1082+
test('renders Requests (24h) and Requests (total) columns per row', async () => {
1083+
(api.getApiKeys as jest.Mock).mockResolvedValue({
1084+
api_keys: [
1085+
{
1086+
id: 'key-1',
1087+
name: 'k1',
1088+
key_prefix: 'abc12345',
1089+
is_active: true,
1090+
created_at: '2024-01-15T10:00:00Z',
1091+
request_count_24h: 12,
1092+
request_count_total: 1234,
1093+
},
1094+
],
1095+
});
1096+
1097+
await loadApiKeys();
1098+
1099+
const container = document.getElementById('apikeys-list');
1100+
expect(container?.innerHTML).toContain('Requests (24h)');
1101+
expect(container?.innerHTML).toContain('Requests (total)');
1102+
// Counts render with thousands separator for readability.
1103+
expect(container?.innerHTML).toContain('1,234');
1104+
expect(container?.innerHTML).toContain('apikeys-count-cell');
1105+
});
1106+
1107+
test('falls back to 0 when counter fields are missing from the response', async () => {
1108+
(api.getApiKeys as jest.Mock).mockResolvedValue({
1109+
api_keys: [
1110+
// No request_count_* fields — simulates a cached older response.
1111+
{ id: 'key-1', name: 'k1', key_prefix: 'abc12345', is_active: true, created_at: '2024-01-15T10:00:00Z' },
1112+
],
1113+
});
1114+
1115+
await loadApiKeys();
1116+
1117+
const container = document.getElementById('apikeys-list');
1118+
const cells = container?.querySelectorAll('.apikeys-count-cell');
1119+
expect(cells?.length).toBe(2);
1120+
expect(cells?.[0]?.textContent).toBe('0');
1121+
expect(cells?.[1]?.textContent).toBe('0');
1122+
});
1123+
1124+
test('list-fetch failure clears summary skeleton and shows error', async () => {
1125+
(api.getApiKeys as jest.Mock).mockRejectedValue(new Error('list boom'));
1126+
(api.getApiKeysUsageStats as jest.Mock).mockResolvedValue({
1127+
total_active: 0,
1128+
total_requests_24h: 0,
1129+
total_requests_lifetime: 0,
1130+
top_keys: [],
1131+
});
1132+
1133+
await loadApiKeys();
1134+
await new Promise((r) => setTimeout(r, 0));
1135+
1136+
const list = document.getElementById('apikeys-list');
1137+
expect(list?.querySelector('.error')?.textContent).toBe('Failed to load API keys');
1138+
// The skeleton-active marker is gone (success/error both clear it).
1139+
expect(list?.dataset['skeletonActive']).toBeUndefined();
1140+
});
1141+
});
10031142
});

‎frontend/src/api/apikeys.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33
*/
44

55
import { apiRequest } from './client';
6-
import type { GetAPIKeysResponse, CreateAPIKeyRequest, CreateAPIKeyResponse } from './types';
6+
import type {
7+
GetAPIKeysResponse,
8+
CreateAPIKeyRequest,
9+
CreateAPIKeyResponse,
10+
APIKeysUsageStats,
11+
} from './types';
712

813
/**
914
* Get all API keys
@@ -12,6 +17,15 @@ export async function getApiKeys(): Promise<GetAPIKeysResponse> {
1217
return apiRequest<GetAPIKeysResponse>('/api-keys');
1318
}
1419

20+
/**
21+
* Get section-level API keys usage stats (issue #340/#344 deferred
22+
* sub-task). Returns the per-section summary card -- totals + top keys
23+
* by 24h activity. Scoped to the calling user's own keys.
24+
*/
25+
export async function getApiKeysUsageStats(): Promise<APIKeysUsageStats> {
26+
return apiRequest<APIKeysUsageStats>('/api-keys/usage-stats');
27+
}
28+
1529
/**
1630
* Create a new API key
1731
*/

‎frontend/src/api/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export type {
3939
CreateAPIKeyRequest,
4040
CreateAPIKeyResponse,
4141
GetAPIKeysResponse,
42+
APIKeysUsageStats,
43+
APIKeysUsageStatsTopKey,
4244
SavingsAnalyticsResponse,
4345
SavingsAnalyticsSummary,
4446
SavingsDataPoint,
@@ -168,6 +170,7 @@ export {
168170
// Re-export apikeys functions
169171
export {
170172
getApiKeys,
173+
getApiKeysUsageStats,
171174
createApiKey,
172175
revokeApiKey,
173176
deleteApiKey

‎frontend/src/api/types.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,26 @@ export interface APIKeyInfo {
526526
created_at: string;
527527
last_used_at?: string;
528528
permissions?: Permission[];
529+
// Usage counters (issue #340/#344 deferred sub-task).
530+
// Optional in the type so older mock data / cached responses without
531+
// the columns don't blow up the renderer -- but the backend always
532+
// sends them as of migration 000094.
533+
request_count_total?: number;
534+
request_count_24h?: number;
535+
}
536+
537+
export interface APIKeysUsageStatsTopKey {
538+
id: string;
539+
name: string;
540+
key_prefix: string;
541+
request_count_24h: number;
542+
}
543+
544+
export interface APIKeysUsageStats {
545+
total_active: number;
546+
total_requests_24h: number;
547+
total_requests_lifetime: number;
548+
top_keys: APIKeysUsageStatsTopKey[];
529549
}
530550

531551
export interface CreateAPIKeyRequest {

0 commit comments

Comments
 (0)