From 39bb9515c6e784a7d2de458260cdf434769f928f Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Sat, 12 Sep 2026 15:36:39 -0500 Subject: [PATCH 1/2] Show total size on the Storage and Database pages Storage gets a stat row for the whole bucket: total size, object count, and how much of it is orphaned. R2 has no aggregate API, so the summary walks the bucket a page at a time behind its own query with a five minute staleTime rather than riding on every page load; past a 50k object cap it reports floors and says so. The bucket also holds avatars, which mediaFiles never tracks, so only keys matching the study document pattern are eligible to be orphaned. Counting every untracked object would have reported every avatar as reclaimable. Database gets size, total rows and table count. D1 exposes no size API and blocks the page_count pragma, but every query's meta carries size_after, so a no-op SELECT is the cheapest way to read it. Claude-Session: https://claude.ai/code/session_01LqxkXwhjRDsJ1N9cBYpU1n --- packages/web/src/hooks/useAdminQueries.ts | 16 +++++- packages/web/src/lib/queryKeys.ts | 1 + .../routes/_app/_protected/admin/database.tsx | 32 ++++++++++- .../routes/_app/_protected/admin/storage.tsx | 37 +++++++++++- .../server/functions/admin-database.server.ts | 14 ++++- .../functions/admin-storage.functions.ts | 10 +++- .../server/functions/admin-storage.server.ts | 56 +++++++++++++++++++ 7 files changed, 160 insertions(+), 6 deletions(-) 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.' > + + + + + + +
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); From c146646d4537749198af7797f0e41f2988267aee Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Sat, 12 Sep 2026 15:40:07 -0500 Subject: [PATCH 2/2] Drop the ledger stats test that restates the filter tests 'narrows the stats to the active filter' re-seeds the same rows to check what 'filters by status' and 'counts every matching row' already cover between them. Claude-Session: https://claude.ai/code/session_01LqxkXwhjRDsJ1N9cBYpU1n --- ...admin-billing-observability.server.test.ts | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/packages/web/src/server/functions/__tests__/admin-billing-observability.server.test.ts b/packages/web/src/server/functions/__tests__/admin-billing-observability.server.test.ts index 4ee8cc4d..2e3bd0d9 100644 --- a/packages/web/src/server/functions/__tests__/admin-billing-observability.server.test.ts +++ b/packages/web/src/server/functions/__tests__/admin-billing-observability.server.test.ts @@ -248,28 +248,6 @@ describe('getAdminBillingLedger', () => { 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({