diff --git a/app/components/MainComponent.vue b/app/components/MainComponent.vue index 66b7c3dc..df096fb2 100644 --- a/app/components/MainComponent.vue +++ b/app/components/MainComponent.vue @@ -806,7 +806,7 @@ export default defineNuxtComponent({ immediate: false, query: computed(() => { const options = Options.fromRoute(route.value, dateRange.value.since, dateRange.value.until); - return options.toParams(); + return { ...options.toParams(), page: '1', pageSize: '500' }; }) }); diff --git a/server/api/user-metrics.ts b/server/api/user-metrics.ts index a4eeb64b..9e85c42e 100644 --- a/server/api/user-metrics.ts +++ b/server/api/user-metrics.ts @@ -11,6 +11,7 @@ */ import { Options } from '@/model/Options'; +import type { H3Event, EventHandlerRequest } from 'h3'; import { aggregateUserDayRecords, fetchLatestUserReport, @@ -24,11 +25,76 @@ import { isDbConfigured } from '../storage/db'; import { fetchAllTeamMembers } from './seats'; import { restrictUserRowsToSelf } from '../utils/restrict-user-rows'; import { requireTeamMembershipOrAdmin } from '../utils/team-membership'; +import { getSessionLoginForFilter, isUsageAdminForEvent } from '../utils/usage-admin'; +import type { QueryObject } from 'ufo'; // eslint-disable-next-line @typescript-eslint/no-explicit-any import mockUsersOrg28Day from '../../public/mock-data/new-api/organization-users-28-day-report.json'; // eslint-disable-next-line @typescript-eslint/no-explicit-any import mockUsersEnt28Day from '../../public/mock-data/new-api/enterprise-users-28-day-report.json'; +const DEFAULT_USER_METRICS_PAGE_SIZE = 500; +const MAX_USER_METRICS_PAGE_SIZE = 500; + +interface UserMetricsPagination { + page: number; + pageSize: number; + offset: number; +} + +type UserMetricsQuery = QueryObject & { + page?: string; + pageSize?: string; + per_page?: string; + limit?: string; +}; + +function parsePositiveInt(value: unknown): number | undefined { + const raw = Array.isArray(value) ? value[0] : value; + const parsed = typeof raw === 'string' || typeof raw === 'number' + ? Number.parseInt(String(raw), 10) + : NaN; + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +} + +function getPagination(query: UserMetricsQuery): UserMetricsPagination { + const page = parsePositiveInt(query.page) ?? 1; + const requestedPageSize = parsePositiveInt(query.pageSize) + ?? parsePositiveInt(query.per_page) + ?? parsePositiveInt(query.limit) + ?? DEFAULT_USER_METRICS_PAGE_SIZE; + const pageSize = Math.min(requestedPageSize, MAX_USER_METRICS_PAGE_SIZE); + return { + page, + pageSize, + offset: (page - 1) * pageSize, + }; +} + +function applyPage(rows: T[], pagination: UserMetricsPagination): T[] { + return rows.slice(pagination.offset, pagination.offset + pagination.pageSize); +} + +function writePaginationHeaders( + event: H3Event, + pagination: UserMetricsPagination, + totalUsers: number +) { + if (typeof setResponseHeader !== 'function') return; + setResponseHeader(event, 'X-User-Metrics-Page', String(pagination.page)); + setResponseHeader(event, 'X-User-Metrics-Page-Size', String(pagination.pageSize)); + setResponseHeader(event, 'X-User-Metrics-Total-Count', String(totalUsers)); + setResponseHeader(event, 'X-User-Metrics-Total-Pages', String(Math.max(1, Math.ceil(totalUsers / pagination.pageSize)))); +} + +async function getSelfFilterLogin(event: H3Event): Promise { + try { + if (await isUsageAdminForEvent(event)) return undefined; + return (await getSessionLoginForFilter(event)) ?? undefined; + } catch { + return undefined; + } +} + /** * If the request is for a team scope, resolve team members and filter * the user totals to only include team members. @@ -96,8 +162,9 @@ function filterDaysByDateRange(records: UserDayRecord[], since?: string, until?: export default defineEventHandler(async (event) => { const logger = console; - const query = getQuery(event); + const query = getQuery(event) as UserMetricsQuery; const options = Options.fromQuery(query); + const pagination = getPagination(query); // GDPR / issue #398 — non-admins may only query teams they belong to. // No-op for admins, PAT-mode operators, and queries without ?githubTeam. @@ -127,7 +194,9 @@ export default defineEventHandler(async (event) => { const members = await filterByTeamIfNeeded(userTotals, options, new Headers()); userTotals = members; } - return restrictUserRowsToSelf(event, userTotals, { isMocked: true }); + const visibleRows = await restrictUserRowsToSelf(event, userTotals, { isMocked: true }); + writePaginationHeaders(event, pagination, visibleRows.length); + return applyPage(visibleRows, pagination); } // ── Storage / historical mode ─────────────────────────────────────────────── @@ -145,11 +214,23 @@ export default defineEventHandler(async (event) => { try { const scope = options.scope || 'organization'; const identifier = options.githubOrg || options.githubEnt || ''; - const stored = await getUserMetricsByDateRange(scope, identifier, options.since, options.until); + const selfFilterLogin = isTeamScope ? undefined : await getSelfFilterLogin(event); + const storagePage = isTeamScope ? undefined : { + limit: selfFilterLogin ? 1 : pagination.pageSize, + offset: selfFilterLogin ? 0 : pagination.offset, + ...(selfFilterLogin ? { userLogin: selfFilterLogin } : {}), + }; + const stored = await getUserMetricsByDateRange(scope, identifier, options.since, options.until, storagePage); if (stored) { const filtered = await filterByTeamIfNeeded(stored.userTotals, options, event.context.headers); - logger.info(`Returning ${filtered.length} user metrics entries from storage (${stored.reportStartDay}–${stored.reportEndDay})`); - return restrictUserRowsToSelf(event, filtered); + const visibleRows = await restrictUserRowsToSelf(event, filtered); + const totalUsers = isTeamScope || stored.totalUsers === undefined ? visibleRows.length : stored.totalUsers; + const pageRows = isTeamScope || stored.totalUsers === undefined + ? applyPage(visibleRows, pagination) + : visibleRows; + writePaginationHeaders(event, pagination, totalUsers); + logger.info(`Returning ${pageRows.length} user metrics entries from storage (${stored.reportStartDay}–${stored.reportEndDay}; page ${pagination.page}, size ${pagination.pageSize}, total ${totalUsers})`); + return pageRows; } logger.info('No user metrics in storage yet, attempting live fetch'); } catch (err) { @@ -220,8 +301,11 @@ export default defineEventHandler(async (event) => { } const filtered = await filterByTeamIfNeeded(userTotals, options, event.context.headers); - logger.info(`Returned ${filtered.length} user records for ${scope}:${identifier} (${userTotals.length} before team filter)`); - return restrictUserRowsToSelf(event, filtered); + const visibleRows = await restrictUserRowsToSelf(event, filtered); + const pageRows = applyPage(visibleRows, pagination); + writePaginationHeaders(event, pagination, visibleRows.length); + logger.info(`Returned ${pageRows.length} user records for ${scope}:${identifier} (${userTotals.length} before team filter; page ${pagination.page}, size ${pagination.pageSize}, total ${visibleRows.length})`); + return pageRows; } catch (error: unknown) { logger.error('Error fetching user metrics:', error); diff --git a/server/storage/user-metrics-storage.ts b/server/storage/user-metrics-storage.ts index 21e47969..5d1c0f38 100644 --- a/server/storage/user-metrics-storage.ts +++ b/server/storage/user-metrics-storage.ts @@ -16,6 +16,19 @@ import { aggregateUserDayRecords } from '../services/github-copilot-usage-api'; import { baseScope } from './user-day-metrics-storage'; import { getPool } from './db'; +export interface UserMetricsPageOptions { + limit: number; + offset: number; + userLogin?: string; +} + +export interface StoredUserMetricsResult { + reportStartDay: string; + reportEndDay: string; + userTotals: UserTotals[]; + totalUsers?: number; +} + /** * Aggregated user-metrics statistics for one stored window (calendar month). */ @@ -58,10 +71,11 @@ export async function getUserMetricsByDateRange( scope: string, scopeIdentifier: string, since?: string, - until?: string -): Promise<{ reportStartDay: string; reportEndDay: string; userTotals: UserTotals[] } | null> { + until?: string, + page?: UserMetricsPageOptions +): Promise { if (!since && !until) { - return getLatestUserMetrics(scope, scopeIdentifier); + return getLatestUserMetrics(scope, scopeIdentifier, page); } const pool = getPool(); @@ -78,12 +92,53 @@ export async function getUserMetricsByDateRange( values.push(until); conditions.push(`metrics_date <= $${values.length}`); } + if (page?.userLogin) { + values.push(page.userLogin); + conditions.push(`user_login = $${values.length}`); + } - const { rows } = await pool.query( - `SELECT data FROM user_day_metrics WHERE ${conditions.join(' AND ')}`, - values - ); - if (rows.length === 0) return null; + const whereClause = conditions.join(' AND '); + const totalUsers = page + ? Number((await pool.query( + `SELECT COUNT(DISTINCT user_login) AS total_users + FROM user_day_metrics + WHERE ${whereClause}`, + values + )).rows[0]?.total_users ?? 0) + : undefined; + + const rows = page + ? (await pool.query( + `WITH selected_users AS ( + SELECT user_login + FROM user_day_metrics + WHERE ${whereClause} + GROUP BY user_login + ORDER BY user_login ASC + LIMIT $${values.length + 1} OFFSET $${values.length + 2} + ) + SELECT udm.data + FROM user_day_metrics udm + JOIN selected_users su ON su.user_login = udm.user_login + WHERE ${conditions.map((condition) => `udm.${condition}`).join(' AND ')} + ORDER BY udm.user_login ASC, udm.metrics_date ASC`, + [...values, page.limit, page.offset] + )).rows + : (await pool.query( + `SELECT data FROM user_day_metrics WHERE ${whereClause}`, + values + )).rows; + if (rows.length === 0) { + if (page && totalUsers !== undefined) { + return { + reportStartDay: since ?? '', + reportEndDay: until ?? '', + userTotals: [], + totalUsers, + }; + } + return null; + } const records: UserDayRecord[] = rows.map(r => r.data); const sortedDays = records.map(r => r.day).filter(Boolean).sort(); @@ -91,6 +146,7 @@ export async function getUserMetricsByDateRange( reportStartDay: since ?? sortedDays[0] ?? '', reportEndDay: until ?? sortedDays[sortedDays.length - 1] ?? '', userTotals: aggregateUserDayRecords(records), + totalUsers, }; } @@ -100,8 +156,9 @@ export async function getUserMetricsByDateRange( */ export async function getLatestUserMetrics( scope: string, - scopeIdentifier: string -): Promise<{ reportStartDay: string; reportEndDay: string; userTotals: UserTotals[] } | null> { + scopeIdentifier: string, + page?: UserMetricsPageOptions +): Promise { const pool = getPool(); const normalizedScope = baseScope(scope); @@ -118,19 +175,64 @@ export async function getLatestUserMetrics( const minDate = new Date(new Date(maxDate).getTime() - (LATEST_WINDOW_DAYS - 1) * 24 * 60 * 60 * 1000) .toISOString().slice(0, 10); - const { rows } = await pool.query( - `SELECT data FROM user_day_metrics - WHERE scope = $1 AND identifier = $2 - AND metrics_date BETWEEN $3 AND $4`, - [normalizedScope, scopeIdentifier, minDate, maxDate] - ); - if (rows.length === 0) return null; + const totalUsers = page + ? Number((await pool.query( + `SELECT COUNT(DISTINCT user_login) AS total_users FROM user_day_metrics + WHERE scope = $1 AND identifier = $2 + AND metrics_date BETWEEN $3 AND $4 + ${page.userLogin ? 'AND user_login = $5' : ''}`, + page.userLogin + ? [normalizedScope, scopeIdentifier, minDate, maxDate, page.userLogin] + : [normalizedScope, scopeIdentifier, minDate, maxDate] + )).rows[0]?.total_users ?? 0) + : undefined; + + const rows = page + ? (await pool.query( + `WITH selected_users AS ( + SELECT user_login + FROM user_day_metrics + WHERE scope = $1 AND identifier = $2 + AND metrics_date BETWEEN $3 AND $4 + ${page.userLogin ? 'AND user_login = $5' : ''} + GROUP BY user_login + ORDER BY user_login ASC + LIMIT $${page.userLogin ? '6' : '5'} OFFSET $${page.userLogin ? '7' : '6'} + ) + SELECT udm.data + FROM user_day_metrics udm + JOIN selected_users su ON su.user_login = udm.user_login + WHERE udm.scope = $1 AND udm.identifier = $2 + AND udm.metrics_date BETWEEN $3 AND $4 + ORDER BY udm.user_login ASC, udm.metrics_date ASC`, + page.userLogin + ? [normalizedScope, scopeIdentifier, minDate, maxDate, page.userLogin, page.limit, page.offset] + : [normalizedScope, scopeIdentifier, minDate, maxDate, page.limit, page.offset] + )).rows + : (await pool.query( + `SELECT data FROM user_day_metrics + WHERE scope = $1 AND identifier = $2 + AND metrics_date BETWEEN $3 AND $4`, + [normalizedScope, scopeIdentifier, minDate, maxDate] + )).rows; + if (rows.length === 0) { + if (page && totalUsers !== undefined) { + return { + reportStartDay: minDate, + reportEndDay: maxDate, + userTotals: [], + totalUsers, + }; + } + return null; + } const records: UserDayRecord[] = rows.map(r => r.data); return { reportStartDay: minDate, reportEndDay: maxDate, userTotals: aggregateUserDayRecords(records), + totalUsers, }; } diff --git a/tests/user-metrics.spec.ts b/tests/user-metrics.spec.ts index b88cc314..7195a9ab 100644 --- a/tests/user-metrics.spec.ts +++ b/tests/user-metrics.spec.ts @@ -542,7 +542,7 @@ describe('/api/user-metrics handler – historical mode fallback', () => { const result = await handler(makeEvent(false)) expect(result).toEqual(stored.userTotals) - expect(mockGetUserMetricsByDateRange).toHaveBeenCalledWith('organization', 'test-org', undefined, undefined) + expect(mockGetUserMetricsByDateRange).toHaveBeenCalledWith('organization', 'test-org', undefined, undefined, { limit: 500, offset: 0 }) }) it('passes since/until params to storage when date range is specified', async () => { @@ -566,7 +566,7 @@ describe('/api/user-metrics handler – historical mode fallback', () => { const result = await handler(makeEvent(false)) expect(result).toEqual(stored.userTotals) - expect(mockGetUserMetricsByDateRange).toHaveBeenCalledWith('organization', 'test-org', '2026-06-01', '2026-06-12') + expect(mockGetUserMetricsByDateRange).toHaveBeenCalledWith('organization', 'test-org', '2026-06-01', '2026-06-12', { limit: 500, offset: 0 }) } finally { ;(globalThis as any).getQuery = ORIGINAL_GET_QUERY } @@ -723,6 +723,58 @@ describe('/api/user-metrics handler – team filtering', () => { expect(mockFetchAllTeamMembers).not.toHaveBeenCalled() }) + it('caps unpaginated org responses at the server default page size', async () => { + ;(globalThis as any).getQuery = () => ({ + scope: 'organization', + githubOrg: 'test-org', + }) + + const manyUsers = Array.from({ length: 501 }, (_, i) => ({ + ...SAMPLE_USER_REPORT.user_totals[0], + login: `user-${String(i + 1).padStart(3, '0')}`, + user_id: i + 1, + })) + mockGetUserMetricsByDateRange.mockResolvedValue({ + reportStartDay: '2026-03-05', + reportEndDay: '2026-04-01', + userTotals: manyUsers, + }) + + const { default: handler } = await import('../server/api/user-metrics') + const result = await handler(makeEvent(false)) + + expect(Array.isArray(result)).toBe(true) + expect(result).toHaveLength(500) + expect((result as any[])[0].login).toBe('user-001') + expect((result as any[])[499].login).toBe('user-500') + }) + + it('returns the requested user metrics page when page and pageSize are provided', async () => { + ;(globalThis as any).getQuery = () => ({ + scope: 'organization', + githubOrg: 'test-org', + page: '2', + pageSize: '2', + }) + + const users = Array.from({ length: 5 }, (_, i) => ({ + ...SAMPLE_USER_REPORT.user_totals[0], + login: `user-${i + 1}`, + user_id: i + 1, + })) + mockGetUserMetricsByDateRange.mockResolvedValue({ + reportStartDay: '2026-03-05', + reportEndDay: '2026-04-01', + userTotals: users, + }) + + const { default: handler } = await import('../server/api/user-metrics') + const result = await handler(makeEvent(false)) + + expect(Array.isArray(result)).toBe(true) + expect((result as any[]).map(u => u.login)).toEqual(['user-3', 'user-4']) + }) + it('throws 503 for team scope without auth in historical mode', async () => { ;(globalThis as any).getQuery = () => ({ scope: 'organization',