Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/components/MainComponent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
})
});

Expand Down
98 changes: 91 additions & 7 deletions server/api/user-metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/

import { Options } from '@/model/Options';
import type { H3Event, EventHandlerRequest } from 'h3';
import {
aggregateUserDayRecords,
fetchLatestUserReport,
Expand All @@ -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<T>(rows: T[], pagination: UserMetricsPagination): T[] {
return rows.slice(pagination.offset, pagination.offset + pagination.pageSize);
}

function writePaginationHeaders(
event: H3Event<EventHandlerRequest>,
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<EventHandlerRequest>): Promise<string | undefined> {
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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 ───────────────────────────────────────────────
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
136 changes: 119 additions & 17 deletions server/storage/user-metrics-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*/
Expand Down Expand Up @@ -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<StoredUserMetricsResult | null> {
if (!since && !until) {
return getLatestUserMetrics(scope, scopeIdentifier);
return getLatestUserMetrics(scope, scopeIdentifier, page);
}

const pool = getPool();
Expand All @@ -78,19 +92,61 @@ 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();
return {
reportStartDay: since ?? sortedDays[0] ?? '',
reportEndDay: until ?? sortedDays[sortedDays.length - 1] ?? '',
userTotals: aggregateUserDayRecords(records),
totalUsers,
};
}

Expand All @@ -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<StoredUserMetricsResult | null> {
const pool = getPool();
const normalizedScope = baseScope(scope);

Expand All @@ -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,
};
}

Expand Down
Loading
Loading