From 3fb604f5ad1e41f4a95b7516a03831cd97e0e381 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 5 Jul 2026 20:02:45 +0000 Subject: [PATCH] Add admin usage dashboard --- e2e/admin-rbac.spec.ts | 19 + .../client/routes/admin-community-reports.tsx | 6 + .../worker/client/routes/admin-invites.tsx | 6 + packages/worker/client/routes/admin-roles.tsx | 6 + packages/worker/client/routes/admin-usage.tsx | 529 ++++++++++++++++++ packages/worker/client/routes/admin-users.tsx | 6 + packages/worker/client/routes/index.tsx | 3 + .../src/app/admin-usage-data.node.test.ts | 371 ++++++++++++ packages/worker/src/app/admin-usage-data.ts | 426 ++++++++++++++ .../worker/src/app/handlers/admin-usage.ts | 61 ++ packages/worker/src/app/loader-data.ts | 85 +++ packages/worker/src/app/router.ts | 6 + packages/worker/src/app/routes.ts | 2 + packages/worker/src/entitlements/service.ts | 20 +- .../admin/admin-capabilities.node.test.ts | 100 ++++ .../admin/admin-usage-overview.ts | 147 +++++ .../src/mcp/capabilities/admin/domain.ts | 2 + 17 files changed, 1781 insertions(+), 14 deletions(-) create mode 100644 packages/worker/client/routes/admin-usage.tsx create mode 100644 packages/worker/src/app/admin-usage-data.node.test.ts create mode 100644 packages/worker/src/app/admin-usage-data.ts create mode 100644 packages/worker/src/app/handlers/admin-usage.ts create mode 100644 packages/worker/src/mcp/capabilities/admin/admin-usage-overview.ts diff --git a/e2e/admin-rbac.spec.ts b/e2e/admin-rbac.spec.ts index fa902f11ba..722ecad388 100644 --- a/e2e/admin-rbac.spec.ts +++ b/e2e/admin-rbac.spec.ts @@ -29,6 +29,9 @@ test('admin RBAC controls access, role assignment, and privacy boundaries', asyn await page.goto('/admin/users') await expect(page.getByRole('heading', { name: 'Admin users' })).toBeHidden() await expect(page.getByText('Forbidden')).toBeVisible() + await page.goto('/admin/usage') + await expect(page.getByRole('heading', { name: 'Admin usage' })).toBeHidden() + await expect(page.getByText('Forbidden')).toBeVisible() await expect( page.getByRole('link', { name: 'Admin', exact: true }), ).toHaveCount(0) @@ -111,6 +114,22 @@ test('admin RBAC controls access, role assignment, and privacy boundaries', asyn expect(JSON.stringify(memberRecord)).not.toContain('memberPrivateSecret') expect(JSON.stringify(memberRecord)).not.toContain('super-secret-value') + await page.goto('/admin/usage') + await expect(page.getByRole('heading', { name: 'Admin usage' })).toBeVisible() + await expect(page.getByText('Unable to load admin usage.')).toHaveCount(0) + await expect(page.getByText('Current month usage')).toBeVisible() + await expect(page.getByText('memberPrivateSecret')).toHaveCount(0) + await expect(page.getByText('super-secret-value')).toHaveCount(0) + const usageApiResponse = await page.request.get( + '/admin/usage.json?pageSize=100', + ) + expect(usageApiResponse.ok()).toBe(true) + const usagePayload = await usageApiResponse.json() + expect(usagePayload.ok).toBe(true) + expect(JSON.stringify(usagePayload)).not.toContain('memberPrivateSecret') + expect(JSON.stringify(usagePayload)).not.toContain('super-secret-value') + + await page.goto(`/admin/users?pageSize=100&page=${lastUsersPage}`) await page.getByRole('button', { name: memberUser.username }).click() await expect(page.getByText('Account metadata only')).toBeVisible() diff --git a/packages/worker/client/routes/admin-community-reports.tsx b/packages/worker/client/routes/admin-community-reports.tsx index 730898987d..9165c0122d 100644 --- a/packages/worker/client/routes/admin-community-reports.tsx +++ b/packages/worker/client/routes/admin-community-reports.tsx @@ -293,6 +293,12 @@ export function AdminCommunityReportsRoute(handle: Handle) { > View roles + + Usage + } /> diff --git a/packages/worker/client/routes/admin-invites.tsx b/packages/worker/client/routes/admin-invites.tsx index 9296bd3278..a6104fb75c 100644 --- a/packages/worker/client/routes/admin-invites.tsx +++ b/packages/worker/client/routes/admin-invites.tsx @@ -258,6 +258,12 @@ export function AdminInvitesRoute(handle: Handle) { > Roles + + Usage + } /> diff --git a/packages/worker/client/routes/admin-roles.tsx b/packages/worker/client/routes/admin-roles.tsx index e3abf101bf..4caebee41f 100644 --- a/packages/worker/client/routes/admin-roles.tsx +++ b/packages/worker/client/routes/admin-roles.tsx @@ -154,6 +154,12 @@ export function AdminRolesRoute(handle: Handle) { > View users + + Usage + , + metric: AdminUsageRollup['metric'], +) { + return usage.find((item) => item.metric === metric) +} + +function usageHrefForUser(user: AdminUsageUserSummary) { + return `/admin/usage?userId=${user.id}` +} + +export async function adminUsageRouteLoader( + url: URL, + signal: AbortSignal, +): Promise { + const response = await fetch(`${adminUsageApiPath}${url.search}`, { + headers: { Accept: 'application/json' }, + credentials: 'include', + signal, + }) + if (response.status === 401) { + return routeLoaderRedirect('/login') + } + if (response.status === 403) { + throw new Error('You do not have permission to view admin usage.') + } + const payload = await readJson(response) + if (!response.ok || !payload?.ok) { + throw new Error('Unable to load admin usage.') + } + return { adminUsage: payload } +} + +export function AdminUsageRoute(handle: Handle) { + let status: PageStatus = 'loading' + let data: AdminUsageLoaderData | null = null + let message: string | null = null + let loadRequestId = 0 + let lastLoadedHref = '' + let loadingForHref: string | null = null + let lastFailedHref: string | null = null + + function applyData(payload: AdminUsageLoaderData, href: string) { + data = payload + status = 'ready' + message = null + lastLoadedHref = href + lastFailedHref = null + } + + async function loadAdminUsage() { + const href = readCurrentRouterHref(handle) + loadingForHref = href + const requestId = ++loadRequestId + try { + const response = await fetch( + `${adminUsageApiPath}${readRouterSearch(handle)}`, + { + headers: { Accept: 'application/json' }, + credentials: 'include', + }, + ) + if (requestId !== loadRequestId) return + if (response.status === 401) { + window.location.assign('/login') + return + } + if (response.status === 403) { + status = 'error' + message = 'You do not have permission to view admin usage.' + lastFailedHref = href + handle.update() + return + } + const payload = await readJson(response) + if (!response.ok || !payload?.ok) { + throw new Error('Unable to load admin usage.') + } + applyData(payload, href) + handle.update() + } catch (error) { + if (requestId !== loadRequestId) return + status = 'error' + message = + error instanceof Error ? error.message : 'Unable to load admin usage.' + lastFailedHref = href + handle.update() + } finally { + if (requestId === loadRequestId) loadingForHref = null + } + } + + function applyRouteLoaderData(href: string) { + if (!isAdminUsagePath(href)) return false + const routeData = tryConsumeRouteLoaderData(handle, 'adminUsage', href) + if (!routeData) return false + applyData(routeData, href) + return true + } + + const secondaryButtonCss = getSecondaryButtonCss() + const tableCss = { + width: '100%', + borderCollapse: 'collapse' as const, + fontSize: typography.fontSize.sm, + } + const cellCss = { + padding: `${spacing.sm} ${spacing.md}`, + borderBottom: `1px solid ${colors.border}`, + textAlign: 'left' as const, + verticalAlign: 'top' as const, + } + const numericCellCss = { + ...cellCss, + textAlign: 'right' as const, + fontVariantNumeric: 'tabular-nums', + } + + let lastSeenHref = '' + + return () => { + const currentHref = readCurrentRouterHref(handle) + if (currentHref !== lastSeenHref) { + lastSeenHref = currentHref + lastFailedHref = null + } + + const appliedRouteData = applyRouteLoaderData(currentHref) + const needsStaleRefresh = + consumeStaleNavigationData(currentHref) && !appliedRouteData + const needsLoad = + (status === 'loading' || + currentHref !== lastLoadedHref || + needsStaleRefresh) && + currentHref !== lastFailedHref && + loadingForHref !== currentHref + if (!appliedRouteData && needsLoad && typeof document !== 'undefined') { + status = 'loading' + loadingForHref = currentHref + handle.queueTask(loadAdminUsage) + } + + const selectedUser = data?.selectedUser ?? null + const totalPages = data + ? Math.max(1, Math.ceil(data.total / data.pageSize)) + : 1 + + return ( + + + + Users + + + Invites + + + Roles + + + } + /> + {status === 'loading' ? ( +

+ Loading usage… +

+ ) : null} + {message ? ( + + {message} + + ) : null} + {data ? ( + <> + +
+ + + + + + + + + + + + + + + + + + {data.users.map((user) => { + const execute = getMetric( + user.currentMonthUsage, + 'execute', + ) + const packageExport = getMetric( + user.currentMonthUsage, + 'package_export', + ) + const jobRun = getMetric( + user.currentMonthUsage, + 'job_run', + ) + const workflowRun = getMetric( + user.currentMonthUsage, + 'workflow_run', + ) + const serviceRuntime = getMetric( + user.currentMonthUsage, + 'service_runtime', + ) + const outboundFetch = getMetric( + user.currentMonthUsage, + 'outbound_fetch', + ) + const emailSend = getMetric( + user.currentMonthUsage, + 'email_send', + ) + const emailToday = + user.todayCounters.find( + (counter) => + counter.resource === 'email_sends_per_day', + )?.count ?? 0 + return ( + + + + + + + + + + + + + + ) + })} + +
AccountPlanExecutesPackage runsJob runsWorkflow runsService runtimeFetchesEmail sendsEmail todayLive resources
+ + {user.username} + +
+ + {user.email} + +
+ {user.plan ?? 'Legacy/unlimited'} + + {formatInteger(execute?.eventCount ?? 0)} + + {formatInteger(packageExport?.eventCount ?? 0)} + + {formatInteger(jobRun?.eventCount ?? 0)} + + {formatInteger(workflowRun?.eventCount ?? 0)} + + {formatDuration( + serviceRuntime?.totalDurationMs ?? 0, + )} + + {formatInteger(outboundFetch?.eventCount ?? 0)} + + {formatInteger(emailSend?.eventCount ?? 0)} + + {formatInteger(emailToday)} + + {user.resourceCounts.map((resource) => ( + + {resource.label}:{' '} + + {formatInteger(resource.current)} + + + ))} +
+
+ {data.users.length === 0 ? ( +

+ No users found. +

+ ) : null} + {totalPages > 1 ? ( +

+ Page {data.page} of {totalPages} +

+ ) : null} +
+ + {selectedUser ? ( + + 0 + ? `${selectedUser.warnings.length} over 80%` + : 'None', + }, + ]} + /> + {selectedUser.warnings.length > 0 ? ( +
+ Quota watch:{' '} + {selectedUser.warnings + .map( + (item) => + `${item.label} at ${formatPercent(item.percentOfLimit)}`, + ) + .join(', ')} +
+ ) : null} +
+
+

+ Entitlements +

+ + + + + + + + + + + {selectedUser.entitlementConsumption.map((item) => ( + + + + + + + ))} + +
ResourceCurrentLimitUsed
{item.label} + {item.current === null + ? 'Not measured' + : formatInteger(item.current)} + + {formatLimit(item.limit)} + + {formatPercent(item.percentOfLimit)} +
+
+
+

+ Month-over-month usage +

+
+ + + + + {Object.values(usageMetricLabels).map((label) => ( + + ))} + + + + {selectedUser.monthUsage.map((month) => ( + + + {Object.keys(usageMetricLabels).map((metric) => ( + + ))} + + ))} + +
Month + {label} +
{month.month} + {formatInteger( + getMetric( + month.usage, + metric as AdminUsageRollup['metric'], + )?.eventCount ?? 0, + )} +
+
+
+
+
+ ) : null} + + ) : null} + + ) + } +} diff --git a/packages/worker/client/routes/admin-users.tsx b/packages/worker/client/routes/admin-users.tsx index aa16cd17d1..f481ce0fb9 100644 --- a/packages/worker/client/routes/admin-users.tsx +++ b/packages/worker/client/routes/admin-users.tsx @@ -275,6 +275,12 @@ export function AdminUsersRoute(handle: Handle) { description="Review account metadata and manage role assignments. User content is never shown here." actions={ <> + + Usage + = { '/admin/invites': adminInvitesRouteLoader, '/admin/roles': adminRolesRouteLoader, '/admin/community-reports': adminCommunityReportsRouteLoader, + '/admin/usage': adminUsageRouteLoader, '/community': communityRouteLoader, '/community/:listingId': communityDetailRouteLoader, '/oauth/authorize': oauthAuthorizeRouteLoader, @@ -91,6 +93,7 @@ export const clientRoutes = { '/admin/invites': , '/admin/roles': , '/admin/community-reports': , + '/admin/usage': , '/community': , '/community/:listingId': , '/login': , diff --git a/packages/worker/src/app/admin-usage-data.node.test.ts b/packages/worker/src/app/admin-usage-data.node.test.ts new file mode 100644 index 0000000000..d5c017176a --- /dev/null +++ b/packages/worker/src/app/admin-usage-data.node.test.ts @@ -0,0 +1,371 @@ +import { expect, test } from 'vitest' +import { createStableUserIdFromEmail } from '#worker/user-id.ts' +import { + type AdminUsageLoaderData, + type AdminUsageRollup, +} from './loader-data.ts' +import { loadAdminUsageData } from './admin-usage-data.ts' + +type UserRow = { + id: number + username: string + email: string + plan: string | null +} + +type UsageRollupRow = { + user_id: string + metric: string + month: string + event_count: number + error_count: number + total_duration_ms: number + total_cpu_ms: number + total_bytes: number +} + +type CounterRow = { + user_id: string + resource: string + day: string + count: number +} + +type ResourceCount = Partial< + Record< + | 'saved_packages' + | 'scheduled_jobs' + | 'package_services' + | 'repo_sessions' + | 'secrets' + | 'concurrent_workflows', + number + > +> + +function normalizeQuery(query: string) { + return query.replace(/\s+/g, ' ').trim().toLowerCase() +} + +function createAdminUsageTestDb(input: { + users: Array + usageRollups?: Array + dailyCounters?: Array + resourceCounts?: Record +}) { + const users = input.users.map((user) => ({ ...user })) + const usageRollups = input.usageRollups?.map((row) => ({ ...row })) ?? [] + const dailyCounters = input.dailyCounters?.map((row) => ({ ...row })) ?? [] + const resourceCounts = input.resourceCounts ?? {} + + function countForQuery(normalizedQuery: string, userId: string) { + const counts = resourceCounts[userId] ?? {} + if (normalizedQuery.includes('from saved_packages')) { + return counts.saved_packages ?? 0 + } + if (normalizedQuery.includes('from jobs')) { + return counts.scheduled_jobs ?? 0 + } + if (normalizedQuery.includes('from package_runtime_runs')) { + return counts.package_services ?? 0 + } + if (normalizedQuery.includes('from repo_sessions')) { + return counts.repo_sessions ?? 0 + } + if (normalizedQuery.includes('from secret_entries')) { + return counts.secrets ?? 0 + } + if (normalizedQuery.includes('from workflow_runs')) { + return counts.concurrent_workflows ?? 0 + } + return null + } + + const db = { + prepare(query: string) { + const normalizedQuery = normalizeQuery(query) + const createStatement = (params: Array) => ({ + async first() { + if (normalizedQuery.includes('select count(*) as total from users')) { + return { total: users.length } as T + } + if ( + normalizedQuery.includes( + 'select id, username, email, plan from users where id = ?', + ) + ) { + return (users.find((user) => user.id === Number(params[0])) ?? + null) as T | null + } + if (normalizedQuery.includes('from entitlement_daily_counters')) { + const row = dailyCounters.find( + (counter) => + counter.user_id === params[0] && + counter.resource === params[1] && + counter.day === params[2], + ) + return (row ? { count: row.count } : null) as T | null + } + const count = countForQuery(normalizedQuery, String(params[0])) + if (count !== null) return { count } as T + throw new Error(`Unsupported first query: ${query}`) + }, + async all() { + if ( + normalizedQuery.includes( + 'select id, username, email, plan from users order by id asc limit ? offset ?', + ) + ) { + const pageSize = Number(params[0]) + const offset = Number(params[1]) + return { + results: users + .sort((left, right) => left.id - right.id) + .slice(offset, offset + pageSize) as Array, + } + } + if ( + normalizedQuery.includes('from usage_rollups') && + normalizedQuery.includes('where user_id in') + ) { + const month = String(params.at(-1)) + const userIds = params.slice(0, -1).map(String) + return { + results: usageRollups.filter( + (row) => userIds.includes(row.user_id) && row.month === month, + ) as Array, + } + } + if ( + normalizedQuery.includes('from usage_rollups') && + normalizedQuery.includes('where user_id = ?') + ) { + return { + results: usageRollups + .filter((row) => row.user_id === params[0]) + .sort( + (left, right) => + right.month.localeCompare(left.month) || + left.metric.localeCompare(right.metric), + ) as Array, + } + } + return { results: [] as Array } + }, + async run() { + throw new Error(`Unsupported run query: ${query}`) + }, + }) + return { + ...createStatement([]), + bind(...params: Array) { + return createStatement(params) + }, + } + }, + } as unknown as D1Database + + return db +} + +function usageRow( + input: Partial & + Pick, +): UsageRollupRow { + return { + event_count: 0, + error_count: 0, + total_duration_ms: 0, + total_cpu_ms: 0, + total_bytes: 0, + ...input, + } +} + +test('loadAdminUsageData returns zeroed usage for empty rollups', async () => { + const email = 'empty-usage@example.com' + const usageUserId = await createStableUserIdFromEmail(email) + const db = createAdminUsageTestDb({ + users: [ + { + id: 1, + username: 'empty', + email, + plan: 'personal', + }, + ], + resourceCounts: { + [usageUserId]: {}, + }, + }) + + const data = await loadAdminUsageData( + { APP_DB: db } as Env, + 'https://kody.local/admin/usage.json', + new Date('2026-07-05T12:00:00.000Z'), + ) + + expect(data.currentMonth).toBe('2026-07') + expect(data.today).toBe('2026-07-05') + expect(data.users).toHaveLength(1) + expect( + data.users[0]?.currentMonthUsage.every((row) => row.eventCount === 0), + ).toBe(true) + expect(data.users[0]?.todayCounters).toEqual([ + { resource: 'email_sends_per_day', label: 'email sends per day', count: 0 }, + ]) + expect(data.selectedUser?.monthUsage).toEqual([ + { + month: '2026-07', + usage: expect.arrayContaining([ + expect.objectContaining({ metric: 'execute', eventCount: 0 }), + ]), + }, + ]) +}) + +test('loadAdminUsageData aggregates multiple users and warns above eighty percent of plan limits', async () => { + const adminEmail = 'admin-usage@example.com' + const memberEmail = 'member-usage@example.com' + const adminUsageUserId = await createStableUserIdFromEmail(adminEmail) + const memberUsageUserId = await createStableUserIdFromEmail(memberEmail) + const db = createAdminUsageTestDb({ + users: [ + { + id: 1, + username: 'admin', + email: adminEmail, + plan: 'pro', + }, + { + id: 2, + username: 'member', + email: memberEmail, + plan: 'personal', + }, + ], + usageRollups: [ + usageRow({ + user_id: adminUsageUserId, + metric: 'execute', + month: '2026-07', + event_count: 5, + }), + usageRow({ + user_id: memberUsageUserId, + metric: 'job_run', + month: '2026-07', + event_count: 4, + }), + usageRow({ + user_id: memberUsageUserId, + metric: 'job_run', + month: '2026-06', + event_count: 40, + }), + ], + dailyCounters: [ + { + user_id: memberUsageUserId, + resource: 'email_sends_per_day', + day: '2026-07-05', + count: 17, + }, + ], + resourceCounts: { + [adminUsageUserId]: { saved_packages: 3 }, + [memberUsageUserId]: { + saved_packages: 18, + scheduled_jobs: 8, + package_services: 1, + repo_sessions: 2, + secrets: 7, + }, + }, + }) + + const data = await loadAdminUsageData( + { APP_DB: db } as Env, + 'https://kody.local/admin/usage.json?userId=2', + new Date('2026-07-05T12:00:00.000Z'), + ) + + expect(data.users).toHaveLength(2) + expect(getEventCount(data.users[0], 'execute')).toBe(5) + expect(getEventCount(data.users[1], 'job_run')).toBe(4) + expect(data.users[1]?.todayCounters[0]?.count).toBe(17) + expect(data.selectedUser?.id).toBe(2) + expect( + data.selectedUser?.warnings.map((warning) => warning.resource), + ).toEqual(['saved_packages', 'email_sends_per_day']) +}) + +test('loadAdminUsageData keeps current-month and month-over-month rollups on UTC month boundaries', async () => { + const email = 'month-boundary@example.com' + const usageUserId = await createStableUserIdFromEmail(email) + const db = createAdminUsageTestDb({ + users: [ + { + id: 1, + username: 'boundary', + email, + plan: null, + }, + ], + usageRollups: [ + usageRow({ + user_id: usageUserId, + metric: 'execute', + month: '2026-06', + event_count: 12, + }), + usageRow({ + user_id: usageUserId, + metric: 'execute', + month: '2026-07', + event_count: 2, + }), + ], + resourceCounts: { + [usageUserId]: {}, + }, + }) + + const data = await loadAdminUsageData( + { APP_DB: db } as Env, + 'https://kody.local/admin/usage.json', + new Date('2026-07-01T00:00:00.000Z'), + ) + + expect(getEventCount(data.users[0], 'execute')).toBe(2) + expect(data.selectedUser?.monthUsage.map((month) => month.month)).toEqual([ + '2026-07', + '2026-06', + ]) + expect( + getEventCountFromUsage( + data.selectedUser?.monthUsage[0]?.usage ?? [], + 'execute', + ), + ).toBe(2) + expect( + getEventCountFromUsage( + data.selectedUser?.monthUsage[1]?.usage ?? [], + 'execute', + ), + ).toBe(12) +}) + +function getEventCount( + user: AdminUsageLoaderData['users'][number] | undefined, + metric: AdminUsageRollup['metric'], +) { + return getEventCountFromUsage(user?.currentMonthUsage ?? [], metric) +} + +function getEventCountFromUsage( + usage: Array, + metric: AdminUsageRollup['metric'], +) { + return usage.find((row) => row.metric === metric)?.eventCount ?? 0 +} diff --git a/packages/worker/src/app/admin-usage-data.ts b/packages/worker/src/app/admin-usage-data.ts new file mode 100644 index 0000000000..470ee828fe --- /dev/null +++ b/packages/worker/src/app/admin-usage-data.ts @@ -0,0 +1,426 @@ +import { + entitlementResourceLabels, + parsePlanName, + resolvePlanLimit, + type EntitlementResource, + type PlanName, +} from '#worker/entitlements/plans.ts' +import { + readEntitlementResourceUsage, + utcDayKey, +} from '#worker/entitlements/service.ts' +import { createStableUserIdFromEmail } from '#worker/user-id.ts' +import { + type AdminUsageDailyCounter, + type AdminUsageEntitlementConsumption, + type AdminUsageMetric, + type AdminUsageLoaderData, + type AdminUsageMonthRollup, + type AdminUsageResourceCount, + type AdminUsageRollup, + type AdminUsageSelectedUser, + type AdminUsageUserSummary, +} from './loader-data.ts' + +export const adminUsageMetrics = [ + 'execute', + 'package_export', + 'job_run', + 'workflow_run', + 'service_runtime', + 'outbound_fetch', + 'email_send', +] as const satisfies ReadonlyArray + +export const adminUsageLiveResourceCounts = [ + 'saved_packages', + 'scheduled_jobs', + 'package_services', + 'repo_sessions', + 'secrets', +] as const satisfies ReadonlyArray + +const adminUsageEntitlementResources = [ + 'saved_packages', + 'scheduled_jobs', + 'package_services', + 'persistent_package_services', + 'repo_sessions', + 'email_sends_per_day', + 'secrets', + 'concurrent_workflows', +] as const satisfies ReadonlyArray + +const adminUsageDailyCounterResources = [ + 'email_sends_per_day', +] as const satisfies ReadonlyArray + +const defaultPageSize = 20 +const maxPageSize = 100 +const warningThreshold = 0.8 + +type AdminUsageUserRow = { + id: number + username: string + email: string + plan: string | null +} + +type AdminUsageRollupRow = { + user_id: string + metric: string + month: string + event_count: number + error_count: number + total_duration_ms: number + total_cpu_ms: number + total_bytes: number +} + +type UserWithUsageId = { + id: number + username: string + email: string + plan: PlanName | null + usageUserId: string +} + +export async function loadAdminUsageData( + env: Env, + requestUrl: string, + now: Date = new Date(), +): Promise { + const url = new URL(requestUrl, 'http://localhost') + const page = readPositiveInt(url.searchParams.get('page'), 1) + const pageSize = Math.min( + readPositiveInt(url.searchParams.get('pageSize'), defaultPageSize), + maxPageSize, + ) + const offset = (page - 1) * pageSize + const selectedUserId = readPositiveInt(url.searchParams.get('userId'), 0) + const currentMonth = utcMonthKey(now) + const today = utcDayKey(now) + + const [totalResult, userRows] = await Promise.all([ + env.APP_DB.prepare(`SELECT COUNT(*) AS total FROM users`).first<{ + total: number + }>(), + env.APP_DB.prepare( + `SELECT id, username, email, plan + FROM users + ORDER BY id ASC + LIMIT ? OFFSET ?`, + ) + .bind(pageSize, offset) + .all(), + ]) + + const users = await addUsageIds(userRows.results ?? []) + const selectedUser = await loadSelectedUser({ + db: env.APP_DB, + users, + selectedUserId, + }) + + const usageRows = await loadCurrentMonthRollups({ + db: env.APP_DB, + userIds: users.map((user) => user.usageUserId), + month: currentMonth, + }) + const usageByUserId = groupRollupsByUserId(usageRows) + + const summaries = await Promise.all( + users.map((user) => + buildUserSummary({ + db: env.APP_DB, + user, + usage: usageByUserId.get(user.usageUserId) ?? [], + now, + }), + ), + ) + + return { + ok: true, + users: summaries, + selectedUser: selectedUser + ? await buildSelectedUser({ + db: env.APP_DB, + user: selectedUser, + currentMonth, + now, + }) + : null, + page, + pageSize, + total: totalResult?.total ?? 0, + currentMonth, + today, + } +} + +async function addUsageIds( + rows: Array, +): Promise> { + return await Promise.all( + rows.map(async (row) => ({ + id: row.id, + username: row.username, + email: row.email, + plan: parsePlanName(row.plan), + usageUserId: await createStableUserIdFromEmail(row.email), + })), + ) +} + +async function loadSelectedUser(input: { + db: D1Database + users: Array + selectedUserId: number +}) { + if (input.users.length === 0) return null + if (input.selectedUserId === 0) return input.users[0] ?? null + const visibleUser = input.users.find( + (user) => user.id === input.selectedUserId, + ) + if (visibleUser) return visibleUser + + const row = await input.db + .prepare(`SELECT id, username, email, plan FROM users WHERE id = ?`) + .bind(input.selectedUserId) + .first() + if (!row) return input.users[0] ?? null + const [user] = await addUsageIds([row]) + return user ?? null +} + +async function buildUserSummary(input: { + db: D1Database + user: UserWithUsageId + usage: Array + now: Date +}): Promise { + const [todayCounters, resourceCounts] = await Promise.all([ + readDailyCounters(input), + readResourceCounts(input), + ]) + return { + id: input.user.id, + username: input.user.username, + email: input.user.email, + plan: input.user.plan, + currentMonthUsage: toCompleteUsage(input.usage), + todayCounters, + resourceCounts, + } +} + +async function buildSelectedUser(input: { + db: D1Database + user: UserWithUsageId + currentMonth: string + now: Date +}): Promise { + const [summary, monthRows, entitlementConsumption] = await Promise.all([ + buildUserSummary({ + db: input.db, + user: input.user, + usage: await loadCurrentMonthRollups({ + db: input.db, + userIds: [input.user.usageUserId], + month: input.currentMonth, + }), + now: input.now, + }), + loadUserMonthRollups({ + db: input.db, + userId: input.user.usageUserId, + }), + readEntitlementConsumption({ + db: input.db, + user: input.user, + now: input.now, + }), + ]) + const warnings = entitlementConsumption.filter( + (item) => item.overEightyPercent, + ) + return { + ...summary, + monthUsage: toMonthUsage(monthRows, input.currentMonth), + entitlementConsumption, + warnings, + } +} + +async function readDailyCounters(input: { + db: D1Database + user: UserWithUsageId + now: Date +}): Promise> { + return await Promise.all( + adminUsageDailyCounterResources.map(async (resource) => ({ + resource, + label: entitlementResourceLabels[resource], + count: await readEntitlementResourceUsage({ + db: input.db, + userId: input.user.usageUserId, + resource, + now: input.now, + }), + })), + ) +} + +async function readResourceCounts(input: { + db: D1Database + user: UserWithUsageId + now: Date +}): Promise> { + return await Promise.all( + adminUsageLiveResourceCounts.map(async (resource) => ({ + resource, + label: entitlementResourceLabels[resource], + current: await readEntitlementResourceUsage({ + db: input.db, + userId: input.user.usageUserId, + resource, + now: input.now, + }), + })), + ) +} + +async function readEntitlementConsumption(input: { + db: D1Database + user: UserWithUsageId + now: Date +}): Promise> { + return await Promise.all( + adminUsageEntitlementResources.map(async (resource) => { + const current = await readEntitlementResourceUsage({ + db: input.db, + userId: input.user.usageUserId, + resource, + now: input.now, + }) + const limit = input.user.plan + ? resolvePlanLimit(input.user.plan, resource) + : null + const percentOfLimit = + limit == null || limit === 0 ? null : current / limit + return { + resource, + label: entitlementResourceLabels[resource], + current, + limit, + percentOfLimit, + overEightyPercent: + percentOfLimit !== null && percentOfLimit > warningThreshold, + } + }), + ) +} + +async function loadCurrentMonthRollups(input: { + db: D1Database + userIds: Array + month: string +}) { + if (input.userIds.length === 0) return [] + const placeholders = input.userIds.map(() => '?').join(', ') + const result = await input.db + .prepare( + `SELECT user_id, metric, month, event_count, error_count, + total_duration_ms, total_cpu_ms, total_bytes + FROM usage_rollups + WHERE user_id IN (${placeholders}) AND month = ?`, + ) + .bind(...input.userIds, input.month) + .all() + return result.results ?? [] +} + +async function loadUserMonthRollups(input: { db: D1Database; userId: string }) { + const result = await input.db + .prepare( + `SELECT user_id, metric, month, event_count, error_count, + total_duration_ms, total_cpu_ms, total_bytes + FROM usage_rollups + WHERE user_id = ? + ORDER BY month DESC, metric ASC`, + ) + .bind(input.userId) + .all() + return result.results ?? [] +} + +function groupRollupsByUserId(rows: Array) { + const byUserId = new Map>() + for (const row of rows) { + const current = byUserId.get(row.user_id) ?? [] + current.push(row) + byUserId.set(row.user_id, current) + } + return byUserId +} + +function toMonthUsage( + rows: Array, + currentMonth: string, +): Array { + const byMonth = new Map>() + for (const row of rows) { + const current = byMonth.get(row.month) ?? [] + current.push(row) + byMonth.set(row.month, current) + } + if (!byMonth.has(currentMonth)) { + byMonth.set(currentMonth, []) + } + return Array.from(byMonth.entries()) + .sort(([left], [right]) => right.localeCompare(left)) + .slice(0, 12) + .map(([month, usage]) => ({ month, usage: toCompleteUsage(usage) })) +} + +function toCompleteUsage(rows: Array) { + const byMetric = new Map() + for (const row of rows) { + if (isAdminUsageMetric(row.metric)) byMetric.set(row.metric, row) + } + return adminUsageMetrics.map((metric) => + toUsageRollup(metric, byMetric.get(metric)), + ) +} + +function toUsageRollup( + metric: AdminUsageMetric, + row: AdminUsageRollupRow | undefined, +): AdminUsageRollup { + return { + metric, + eventCount: Number(row?.event_count ?? 0), + errorCount: Number(row?.error_count ?? 0), + totalDurationMs: Number(row?.total_duration_ms ?? 0), + totalCpuMs: Number(row?.total_cpu_ms ?? 0), + totalBytes: Number(row?.total_bytes ?? 0), + } +} + +function isAdminUsageMetric(metric: string): metric is AdminUsageMetric { + return (adminUsageMetrics as ReadonlyArray).includes(metric) +} + +function utcMonthKey(date: Date) { + return date.toISOString().slice(0, 'YYYY-MM'.length) +} + +function readPositiveInt(value: string | null, fallback: number) { + if (!value) return fallback + const parsed = Number.parseInt(value, 10) + if (!Number.isFinite(parsed) || parsed < 1) { + return fallback + } + return parsed +} diff --git a/packages/worker/src/app/handlers/admin-usage.ts b/packages/worker/src/app/handlers/admin-usage.ts new file mode 100644 index 0000000000..a8555b065c --- /dev/null +++ b/packages/worker/src/app/handlers/admin-usage.ts @@ -0,0 +1,61 @@ +import { type Action } from 'remix/router' +import { loadAdminUsageData } from '#app/admin-usage-data.ts' +import { readAuthSessionResult } from '#app/auth-session.ts' +import { redirectToLogin } from '#app/auth-redirect.ts' +import { requireUserWithRole } from '#app/permissions-server.ts' +import { type routes } from '#app/routes.ts' +import { renderAppPage } from '#app/ssr-render.tsx' + +export function createAdminUsageHandler(env: Env) { + return { + middleware: [], + async handler({ request }) { + try { + await requireUserWithRole(request, env, 'admin') + } catch (error) { + if (error instanceof Response) return error + throw error + } + + const { session } = await readAuthSessionResult(request) + if (!session) { + return redirectToLogin(request) + } + + const adminUsage = await loadAdminUsageData(env, request.url) + + return renderAppPage({ + request, + env, + title: 'Admin usage', + loaderData: { adminUsage }, + }) + }, + } satisfies Action +} + +export function createAdminUsageApiHandler(env: Env) { + return { + middleware: [], + async handler({ request }) { + try { + await requireUserWithRole(request, env, 'admin') + const payload = await loadAdminUsageData(env, request.url) + return jsonResponse(payload) + } catch (error) { + if (error instanceof Response) return error + throw error + } + }, + } satisfies Action +} + +function jsonResponse(body: Record, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': 'application/json; charset=utf-8', + }, + }) +} diff --git a/packages/worker/src/app/loader-data.ts b/packages/worker/src/app/loader-data.ts index 06a80f2339..233c76711f 100644 --- a/packages/worker/src/app/loader-data.ts +++ b/packages/worker/src/app/loader-data.ts @@ -93,6 +93,90 @@ export type AdminInvitesLoaderData = { invites: Array } +export type AdminUsageMetric = + | 'execute' + | 'package_export' + | 'job_run' + | 'workflow_run' + | 'service_runtime' + | 'outbound_fetch' + | 'email_send' + +export type AdminUsageEntitlementResource = + | 'saved_packages' + | 'scheduled_jobs' + | 'package_services' + | 'persistent_package_services' + | 'repo_sessions' + | 'email_sends_per_day' + | 'secrets' + | 'concurrent_workflows' + | 'storage_bytes' + +export type AdminUsagePlanName = 'partner' | 'personal' | 'pro' + +export type AdminUsageRollup = { + metric: AdminUsageMetric + eventCount: number + errorCount: number + totalDurationMs: number + totalCpuMs: number + totalBytes: number +} + +export type AdminUsageDailyCounter = { + resource: AdminUsageEntitlementResource + label: string + count: number +} + +export type AdminUsageResourceCount = { + resource: AdminUsageEntitlementResource + label: string + current: number +} + +export type AdminUsageEntitlementConsumption = { + resource: AdminUsageEntitlementResource + label: string + current: number | null + limit: number | null + percentOfLimit: number | null + overEightyPercent: boolean +} + +export type AdminUsageMonthRollup = { + month: string + usage: Array +} + +export type AdminUsageUserSummary = { + id: number + username: string + email: string + plan: AdminUsagePlanName | null + currentMonthUsage: Array + todayCounters: Array + resourceCounts: Array +} + +export type AdminUsageSelectedUser = AdminUsageUserSummary & { + monthUsage: Array + entitlementConsumption: Array + warnings: Array +} + +export type AdminUsageLoaderData = { + ok: true + users: Array + selectedUser: AdminUsageSelectedUser | null + page: number + pageSize: number + total: number + currentMonth: string + today: string +} + export type AdminCreatedUserSetup = { userId: number email: string @@ -259,6 +343,7 @@ export type AppLoaderData = { adminRoles?: AdminRolesLoaderData adminCommunityReports?: AdminCommunityReportsLoaderData adminInvites?: AdminInvitesLoaderData + adminUsage?: AdminUsageLoaderData accountProfile?: AccountProfileLoaderData accountIntegrations?: AccountIntegrationsLoaderData accountRemoteConnectors?: AccountRemoteConnectorsLoaderData diff --git a/packages/worker/src/app/router.ts b/packages/worker/src/app/router.ts index 3fb6fca4fb..d8bdbb4b2a 100644 --- a/packages/worker/src/app/router.ts +++ b/packages/worker/src/app/router.ts @@ -16,6 +16,10 @@ import { createAdminRolesApiHandler, createAdminRolesHandler, } from '#app/handlers/admin-roles.ts' +import { + createAdminUsageApiHandler, + createAdminUsageHandler, +} from '#app/handlers/admin-usage.ts' import { createAccountHandler } from '#app/handlers/account.ts' import { createAccountDeleteHandler } from '#app/handlers/account-delete.ts' import { @@ -131,6 +135,8 @@ export function createAppRouter(appEnv: AppEnv) { adminCommunityReports: createAdminCommunityReportsHandler(env), adminCommunityReportsApi: createAdminCommunityReportsApiHandler(env), adminCommunityReportsApiPost: createAdminCommunityReportsApiHandler(env), + adminUsage: createAdminUsageHandler(env), + adminUsageApi: createAdminUsageApiHandler(env), community: createCommunityHandler(env), communityApi: createCommunityApiHandler(env), communityDetail: createCommunityDetailHandler(env), diff --git a/packages/worker/src/app/routes.ts b/packages/worker/src/app/routes.ts index 45ab5057b6..ba426f2eaa 100644 --- a/packages/worker/src/app/routes.ts +++ b/packages/worker/src/app/routes.ts @@ -40,6 +40,8 @@ export const routes = route({ adminCommunityReports: '/admin/community-reports', adminCommunityReportsApi: '/admin/community-reports.json', adminCommunityReportsApiPost: post('/admin/community-reports.json'), + adminUsage: '/admin/usage', + adminUsageApi: '/admin/usage.json', community: '/community', communityApi: '/community.json', communityDetail: '/community/:listingId', diff --git a/packages/worker/src/entitlements/service.ts b/packages/worker/src/entitlements/service.ts index 9d13c4c71f..63c2ba3492 100644 --- a/packages/worker/src/entitlements/service.ts +++ b/packages/worker/src/entitlements/service.ts @@ -139,7 +139,7 @@ export async function countRunningPackageServices(input: { ) } -async function countEntitlementUsage(input: { +export async function readEntitlementResourceUsage(input: { db: D1Database userId: string resource: EntitlementResource @@ -160,19 +160,11 @@ async function countEntitlementUsage(input: { [userId], ) case 'package_services': { - const windowStart = new Date( - now.valueOf() - runningServiceCountWindowMs, - ).toISOString() - return await countRows( + return await countRunningPackageServices({ db, - `SELECT COUNT(DISTINCT package_id || '/' || COALESCE(name, '')) AS count - FROM package_runtime_runs - WHERE user_id = ? - AND surface = 'service' - AND status = 'running' - AND started_at >= ?`, - [userId, windowStart], - ) + userId, + now, + }) } case 'persistent_package_services': // Boolean allowance: the limit is 0 (not allowed) or null @@ -270,7 +262,7 @@ export async function assertWithinEntitlement( const requested = input.requested ?? 1 const current = input.getCurrent ? await input.getCurrent() - : await countEntitlementUsage({ + : await readEntitlementResourceUsage({ db: input.db, userId: input.userId, resource: input.resource, diff --git a/packages/worker/src/mcp/capabilities/admin/admin-capabilities.node.test.ts b/packages/worker/src/mcp/capabilities/admin/admin-capabilities.node.test.ts index d9efe6f894..a98819d76c 100644 --- a/packages/worker/src/mcp/capabilities/admin/admin-capabilities.node.test.ts +++ b/packages/worker/src/mcp/capabilities/admin/admin-capabilities.node.test.ts @@ -1,6 +1,7 @@ import { expect, test } from 'vitest' import { createMcpCallerContext } from '#mcp/context.ts' import { adminAuditLogQueryCapability } from './admin-audit-log-query.ts' +import { adminUsageOverviewCapability } from './admin-usage-overview.ts' import { adminUserCreateCapability } from './admin-user-create.ts' import { adminUserGetCapability } from './admin-user-get.ts' import { adminUserListCapability } from './admin-user-list.ts' @@ -9,6 +10,7 @@ type UserRow = { id: number username: string email: string + plan?: string | null created_at: string updated_at: string password_hash?: string @@ -95,6 +97,21 @@ function createAdminCapabilityTestDb(input: { return rows.slice(offset, offset + limit) } + function countForQuery(normalizedQuery: string) { + const tableNames = [ + 'saved_packages', + 'jobs', + 'repo_sessions', + 'package_runtime_runs', + 'secret_entries', + 'workflow_runs', + ] as const + for (const table of tableNames) { + if (normalizedQuery.includes(`from ${table}`)) return 0 + } + return null + } + const db = { prepare(query: string) { const normalizedQuery = normalizeQuery(query) @@ -111,6 +128,14 @@ function createAdminCapabilityTestDb(input: { return (users.find((user) => user.id === params[0]) ?? null) as T | null } + if ( + normalizedQuery.includes( + 'select id, username, email, plan from users where id = ?', + ) + ) { + return (users.find((user) => user.id === Number(params[0])) ?? + null) as T | null + } if ( normalizedQuery.includes( 'select id, username, email, created_at, updated_at from users where email = ? collate nocase', @@ -143,6 +168,11 @@ function createAdminCapabilityTestDb(input: { }).length, } as T } + if (normalizedQuery.includes('from entitlement_daily_counters')) { + return null + } + const count = countForQuery(normalizedQuery) + if (count !== null) return { count } as T return null }, async all() { @@ -159,6 +189,25 @@ function createAdminCapabilityTestDb(input: { .slice(offset, offset + pageSize) as Array, } } + if ( + normalizedQuery.includes( + 'select id, username, email, plan from users order by id asc limit ? offset ?', + ) + ) { + const pageSize = Number(params[0]) + const offset = Number(params[1]) + return { + results: users + .sort((left, right) => left.id - right.id) + .slice(offset, offset + pageSize) + .map((user) => ({ + id: user.id, + username: user.username, + email: user.email, + plan: user.plan ?? null, + })) as Array, + } + } if (normalizedQuery.includes('where ur.user_id in')) { const userIds = params.map((value) => Number(value)) return { @@ -177,6 +226,9 @@ function createAdminCapabilityTestDb(input: { }) as Array, } } + if (normalizedQuery.includes('from usage_rollups')) { + return { results: [] as Array } + } return { results: [] as Array } }, async run() { @@ -349,6 +401,34 @@ test('admin capabilities list and get account metadata and query sanitized audit roles: ['user'], }) + const usage = await adminUsageOverviewCapability.handler( + { pageSize: 10 }, + ctx, + ) + expect(usage).toMatchObject({ + total: 2, + page: 1, + pageSize: 10, + users: [ + expect.objectContaining({ + id: 1, + email: 'admin@example.com', + currentMonthUsage: expect.arrayContaining([ + expect.objectContaining({ metric: 'execute', eventCount: 0 }), + ]), + }), + expect.objectContaining({ + id: 2, + email: 'jane@example.com', + }), + ], + selectedUser: expect.objectContaining({ + id: 1, + entitlementConsumption: expect.any(Array), + monthUsage: expect.any(Array), + }), + }) + const audit = await adminAuditLogQueryCapability.handler( { action: 'admin_user_get', limit: 10 }, ctx, @@ -367,6 +447,7 @@ test('admin capabilities list and get account metadata and query sanitized audit expect(auditEvents.map((event) => event.action)).toEqual([ 'admin_user_list', 'admin_user_get', + 'admin_usage_overview', 'admin_audit_log_query', ]) }) @@ -448,4 +529,23 @@ test('admin capabilities reject non-admin direct handler calls', async () => { ).rejects.toThrow( 'MCP user lacks required role "admin" for capability "admin_user_list".', ) + await expect( + adminUsageOverviewCapability.handler( + {}, + { + env: { APP_DB: db } as Env, + callerContext: createMcpCallerContext({ + baseUrl: 'https://example.com', + user: { + userId: 'user-1', + email: 'user@example.com', + displayName: 'user', + roles: ['user'], + }, + }), + }, + ), + ).rejects.toThrow( + 'MCP user lacks required role "admin" for capability "admin_usage_overview".', + ) }) diff --git a/packages/worker/src/mcp/capabilities/admin/admin-usage-overview.ts b/packages/worker/src/mcp/capabilities/admin/admin-usage-overview.ts new file mode 100644 index 0000000000..7a364dd8e5 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/admin/admin-usage-overview.ts @@ -0,0 +1,147 @@ +import { z } from 'zod' +import { loadAdminUsageData } from '#app/admin-usage-data.ts' +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { + adminCapabilityAccess, + auditAdminCapabilityInvocation, +} from './admin-shared.ts' + +const usageMetricSchema = z.enum([ + 'execute', + 'package_export', + 'job_run', + 'workflow_run', + 'service_runtime', + 'outbound_fetch', + 'email_send', +]) + +const entitlementResourceSchema = z.enum([ + 'saved_packages', + 'scheduled_jobs', + 'package_services', + 'persistent_package_services', + 'repo_sessions', + 'email_sends_per_day', + 'secrets', + 'concurrent_workflows', + 'storage_bytes', +]) + +const planSchema = z.enum(['partner', 'personal', 'pro']).nullable() + +const usageRollupSchema = z.object({ + metric: usageMetricSchema, + eventCount: z.number().int().nonnegative(), + errorCount: z.number().int().nonnegative(), + totalDurationMs: z.number().int().nonnegative(), + totalCpuMs: z.number().int().nonnegative(), + totalBytes: z.number().int().nonnegative(), +}) + +const dailyCounterSchema = z.object({ + resource: entitlementResourceSchema, + label: z.string(), + count: z.number().int().nonnegative(), +}) + +const resourceCountSchema = z.object({ + resource: entitlementResourceSchema, + label: z.string(), + current: z.number().int().nonnegative(), +}) + +const entitlementConsumptionSchema = z.object({ + resource: entitlementResourceSchema, + label: z.string(), + current: z.number().int().nonnegative().nullable(), + limit: z.number().int().nonnegative().nullable(), + percentOfLimit: z.number().nonnegative().nullable(), + overEightyPercent: z.boolean(), +}) + +const userSummarySchema = z.object({ + id: z.number().int().positive(), + username: z.string(), + email: z.string(), + plan: planSchema, + currentMonthUsage: z.array(usageRollupSchema), + todayCounters: z.array(dailyCounterSchema), + resourceCounts: z.array(resourceCountSchema), +}) + +const selectedUserSchema = userSummarySchema.extend({ + monthUsage: z.array( + z.object({ + month: z.string(), + usage: z.array(usageRollupSchema), + }), + ), + entitlementConsumption: z.array(entitlementConsumptionSchema), + warnings: z.array(entitlementConsumptionSchema), +}) + +const inputSchema = z.object({ + page: z + .number() + .int() + .min(1) + .optional() + .describe('One-indexed page of users to return. Defaults to 1.'), + pageSize: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe('Users per page. Defaults to 20 and maxes at 100.'), + userId: z + .number() + .int() + .min(1) + .optional() + .describe('Numeric account id to include in the drill-down section.'), +}) + +const outputSchema = z.object({ + users: z.array(userSummarySchema), + selectedUser: selectedUserSchema.nullable(), + page: z.number().int().positive(), + pageSize: z.number().int().positive(), + total: z.number().int().nonnegative(), + currentMonth: z.string(), + today: z.string(), +}) + +export const adminUsageOverviewCapability = defineDomainCapability( + capabilityDomainNames.admin, + { + ...adminCapabilityAccess, + name: 'admin_usage_overview', + description: + 'Read usage rollups, entitlement counters, and resource counts for account metadata. Admin-only; never returns user content.', + keywords: ['admin', 'usage', 'quotas', 'entitlements', 'plans', 'metering'], + inputSchema, + outputSchema, + async handler(args, ctx) { + return auditAdminCapabilityInvocation( + ctx, + 'admin_usage_overview', + async () => { + const url = new URL('https://kody.local/admin/usage.json') + if (args.page) url.searchParams.set('page', String(args.page)) + if (args.pageSize) { + url.searchParams.set('pageSize', String(args.pageSize)) + } + if (args.userId) url.searchParams.set('userId', String(args.userId)) + const { ok: _ok, ...data } = await loadAdminUsageData( + ctx.env, + url.toString(), + ) + return data + }, + ) + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/admin/domain.ts b/packages/worker/src/mcp/capabilities/admin/domain.ts index feb834ae5c..0cc41cef83 100644 --- a/packages/worker/src/mcp/capabilities/admin/domain.ts +++ b/packages/worker/src/mcp/capabilities/admin/domain.ts @@ -1,6 +1,7 @@ import { defineDomain } from '#mcp/capabilities/define-domain.ts' import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' import { adminAuditLogQueryCapability } from './admin-audit-log-query.ts' +import { adminUsageOverviewCapability } from './admin-usage-overview.ts' import { adminUserCreateCapability } from './admin-user-create.ts' import { adminUserGetCapability } from './admin-user-get.ts' import { adminUserListCapability } from './admin-user-list.ts' @@ -15,5 +16,6 @@ export const adminDomain = defineDomain({ adminUserGetCapability, adminUserCreateCapability, adminAuditLogQueryCapability, + adminUsageOverviewCapability, ], })