diff --git a/packages/web/src/hooks/useAdminQueries.ts b/packages/web/src/hooks/useAdminQueries.ts index c6d47f21..13e52dd2 100644 --- a/packages/web/src/hooks/useAdminQueries.ts +++ b/packages/web/src/hooks/useAdminQueries.ts @@ -25,7 +25,10 @@ import { getAdminBillingLedgerAction, getAdminBillingStuckStatesAction, } from '@/server/functions/admin-billing.functions'; -import { listAdminStorageDocumentsAction } from '@/server/functions/admin-storage.functions'; +import { + listAdminStorageDocumentsAction, + getAdminStorageSummaryAction, +} from '@/server/functions/admin-storage.functions'; import { listAdminDatabaseTablesAction, getAdminTableSchemaAction, @@ -213,6 +216,17 @@ export function useAdminOrgBillingReconcile( }); } +// A full bucket walk, so it is not refetched on every mount the way the +// cheaper admin queries are. +export function useAdminStorageSummary() { + return useQuery({ + queryKey: queryKeys.admin.storageSummary, + queryFn: () => getAdminStorageSummaryAction(), + staleTime: 1000 * 60 * 5, + gcTime: 1000 * 60 * 30, + }); +} + export function useAdminDatabaseTables() { return useQuery({ queryKey: queryKeys.admin.databaseTables, diff --git a/packages/web/src/lib/queryKeys.ts b/packages/web/src/lib/queryKeys.ts index 093c04b1..e4d5d736 100644 --- a/packages/web/src/lib/queryKeys.ts +++ b/packages/web/src/lib/queryKeys.ts @@ -85,6 +85,7 @@ export const queryKeys = { ['adminWorkspaceStats', projectId] as const, storageDocuments: (cursor: string | null, limit: number, prefix: string, search: string) => ['storageDocuments', cursor, limit, prefix, search] as const, + storageSummary: ['adminStorageSummary'] as const, billingLedger: (params: Record) => ['adminBillingLedger', params] as const, billingStuckStates: (params: Record) => ['adminBillingStuckStates', params] as const, diff --git a/packages/web/src/routes/_app/_protected/admin/database.tsx b/packages/web/src/routes/_app/_protected/admin/database.tsx index 989e341c..629475ea 100644 --- a/packages/web/src/routes/_app/_protected/admin/database.tsx +++ b/packages/web/src/routes/_app/_protected/admin/database.tsx @@ -35,8 +35,16 @@ import { TableHead, TableCell, } from '@/components/ui/table'; -import { AdminEmpty, AdminPage, AdminPanel, ADMIN_TH } from '@/components/admin/ui'; +import { + AdminEmpty, + AdminPage, + AdminPanel, + AdminStat, + AdminStatRow, + ADMIN_TH, +} from '@/components/admin/ui'; import { navRowClass } from '@/components/layout/navStyles'; +import { formatFileSize } from '@corates/shared'; export const Route = createFileRoute('/_app/_protected/admin/database')({ component: DatabaseViewerPage, @@ -63,6 +71,8 @@ function DatabaseViewerPage() { const tablesQuery = useAdminDatabaseTables(); const tables = tablesQuery.data?.tables ?? []; + const databaseSizeBytes = tablesQuery.data?.databaseSizeBytes ?? 0; + const totalRows = tablesQuery.data?.totalRows ?? 0; const schemaQuery = useAdminTableSchema(selectedTable); const schemaColumns = useMemo(() => schemaQuery.data?.columns ?? [], [schemaQuery.data]); @@ -126,6 +136,26 @@ function DatabaseViewerPage() { return ( + + + + + +
{ setCursor(null); setCursorHistory([]); @@ -159,6 +165,35 @@ function StorageManagementPage() { title='Storage' description='PDFs in R2. Files marked orphaned exist in R2 but are not tracked in the mediaFiles table, usually from a failed cleanup, and are safe to delete.' > + + + + + + +
{ expect(result.stats.byStatus.failed).toBe(2); }); - it('narrows the stats to the active filter', async () => { - const nowSec = Math.floor(Date.now() / 1000); - for (let i = 0; i < 5; i++) { - await seedStripeEventLedger({ - id: `lf${i}`, - payloadHash: `hf${i}`, - receivedAt: nowSec + i, - route: '/webhooks/stripe', - requestId: `rf${i}`, - status: i < 3 ? 'processed' : 'failed', - }); - } - - const result = await getAdminBillingLedger(mockAdminSession(), createDb(env.DB), { - status: 'failed', - limit: 1, - }); - expect(result.entries.length).toBe(1); - expect(result.stats.total).toBe(2); - expect(result.stats.byStatus).toEqual({ failed: 2 }); - }); - it('filters by type', async () => { const nowSec = Math.floor(Date.now() / 1000); await seedStripeEventLedger({ diff --git a/packages/web/src/server/functions/admin-database.server.ts b/packages/web/src/server/functions/admin-database.server.ts index 5dd3b0e1..80a51e91 100644 --- a/packages/web/src/server/functions/admin-database.server.ts +++ b/packages/web/src/server/functions/admin-database.server.ts @@ -1,6 +1,6 @@ import type { Database } from '@corates/db/client'; import { dbSchema, mediaFiles, organization, projects, user } from '@corates/db/schema'; -import { and, asc, count, desc, eq } from 'drizzle-orm'; +import { and, asc, count, desc, eq, sql } from 'drizzle-orm'; import { throwDomainError, AUTH_ERRORS } from '@corates/shared'; import { isAdminUser } from '@corates/workers/auth-admin'; import { ALLOWED_TABLES, isAllowedTable, type AllowedTableName } from '@/server/lib/dbTables'; @@ -29,7 +29,17 @@ export async function listAdminDatabaseTables(session: Session, db: Database) { }), ); - return { tables: tables.filter(t => t !== null) }; + const counted = tables.filter(t => t !== null); + + // D1 exposes no size API and blocks the page_count pragma, but every query's + // meta carries the database size, so a no-op statement is the cheapest read. + const sizeProbe = await db.run(sql`SELECT 1`); + + return { + tables: counted, + totalRows: counted.reduce((sum, t) => sum + t.rowCount, 0), + databaseSizeBytes: sizeProbe.meta?.size_after ?? 0, + }; } interface DrizzleColumn { diff --git a/packages/web/src/server/functions/admin-storage.functions.ts b/packages/web/src/server/functions/admin-storage.functions.ts index b009a6b9..63832472 100644 --- a/packages/web/src/server/functions/admin-storage.functions.ts +++ b/packages/web/src/server/functions/admin-storage.functions.ts @@ -1,7 +1,11 @@ import { createServerFn } from '@tanstack/react-start'; import { z } from 'zod'; import { authMiddleware } from '@/server/middleware/auth'; -import { listAdminStorageDocuments, deleteAdminStorageDocuments } from './admin-storage.server'; +import { + listAdminStorageDocuments, + deleteAdminStorageDocuments, + getAdminStorageSummary, +} from './admin-storage.server'; export const listAdminStorageDocumentsAction = createServerFn({ method: 'GET' }) .middleware([authMiddleware]) @@ -21,3 +25,7 @@ export const deleteAdminStorageDocumentsAction = createServerFn({ method: 'POST' .middleware([authMiddleware]) .validator(z.object({ keys: z.array(z.string()) })) .handler(async ({ data, context: { session } }) => deleteAdminStorageDocuments(session, data)); + +export const getAdminStorageSummaryAction = createServerFn({ method: 'GET' }) + .middleware([authMiddleware]) + .handler(async ({ context: { session, db } }) => getAdminStorageSummary(session, db)); diff --git a/packages/web/src/server/functions/admin-storage.server.ts b/packages/web/src/server/functions/admin-storage.server.ts index 534b688f..91cb1c94 100644 --- a/packages/web/src/server/functions/admin-storage.server.ts +++ b/packages/web/src/server/functions/admin-storage.server.ts @@ -149,6 +149,62 @@ export async function listAdminStorageDocuments( return response; } +// R2 has no aggregate API, so a total means walking the bucket a page at a +// time. The cap keeps one page load bounded; past it the figures are floors. +const SUMMARY_SCAN_CAP = 50000; + +export async function getAdminStorageSummary(session: Session, db: Database) { + assertAdmin(session); + + const trackedKeys = await db.select({ bucketKey: mediaFiles.bucketKey }).from(mediaFiles); + const trackedKeysSet = new Set(trackedKeys.map(row => row.bucketKey)); + + let objectCount = 0; + let totalBytes = 0; + let documentCount = 0; + let documentBytes = 0; + let orphanedCount = 0; + let orphanedBytes = 0; + let cursor: string | undefined = undefined; + let truncated = false; + + while (objectCount < SUMMARY_SCAN_CAP) { + const listOptions: { limit: number; cursor?: string } = { limit: 1000 }; + if (cursor) listOptions.cursor = cursor; + const listed = await env.PDF_BUCKET.list(listOptions); + + for (const obj of listed.objects) { + objectCount += 1; + totalBytes += obj.size; + + // The bucket also holds avatars, which mediaFiles never tracks. Only + // study documents can be orphaned, so only they are classified. + if (!parseKey(obj.key)) continue; + documentCount += 1; + documentBytes += obj.size; + if (!trackedKeysSet.has(obj.key)) { + orphanedCount += 1; + orphanedBytes += obj.size; + } + } + + if (!listed.truncated) break; + cursor = listed.cursor; + if (objectCount >= SUMMARY_SCAN_CAP) truncated = true; + } + + return { + objectCount, + totalBytes, + documentCount, + documentBytes, + orphanedCount, + orphanedBytes, + truncated, + scanCap: SUMMARY_SCAN_CAP, + }; +} + export async function deleteAdminStorageDocuments(session: Session, params: { keys: string[] }) { assertAdmin(session);