From e79e03d8fabbc6d0e66dbdf42c114e04c8d210f6 Mon Sep 17 00:00:00 2001 From: saltbo Date: Wed, 22 Jul 2026 00:45:17 -0400 Subject: [PATCH] fix(stats): preserve missing data in dashboard charts --- e2e/admin-charts.spec.ts | 108 +++ scripts/backfill-admin-stats.ts | 15 + server/adapters/repos/admin-stats-hourly.ts | 16 + .../admin-stats-rollup.integration.test.ts | 32 + server/adapters/repos/admin-stats.ts | 264 ++++-- server/http/admin-stats.integration.test.ts | 98 ++- server/scripts/backfill-admin-stats.test.ts | 6 + .../_authenticated/admin/analytics.test.tsx | 7 +- src/routes/_authenticated/admin/analytics.tsx | 775 ++++++++++-------- 9 files changed, 859 insertions(+), 462 deletions(-) create mode 100644 e2e/admin-charts.spec.ts diff --git a/e2e/admin-charts.spec.ts b/e2e/admin-charts.spec.ts new file mode 100644 index 00000000..e7db1fac --- /dev/null +++ b/e2e/admin-charts.spec.ts @@ -0,0 +1,108 @@ +import { expect, test } from '@playwright/test' +import type { AdminDashboardGrowthStats, AdminOverview } from '@shared/types' +import { signInAsAdmin } from './helpers' + +const completeCoverage = { + status: 'complete', + expectedBuckets: 48, + completedBuckets: 48, + lowerBoundBuckets: 0, + quality: 'exact', + dataThrough: '2026-07-21T23:00:00.000Z', +} as const + +const overview: AdminOverview = { + observedAt: '2026-07-22T00:00:00.000Z', + users: { + total: 42, + active30Days: 18, + new7Days: 5, + activity: { total: 42, today: 3, last7Days: 5, last30Days: 10, inactive: 24 }, + trend: [ + { date: '2026-07-20', totalUsers: 40, activeUsers: 16, newUsers: 2 }, + { date: '2026-07-21', totalUsers: 42, activeUsers: 18, newUsers: 2 }, + ], + topUsage: [], + }, + storages: { + total: 1, + writable: 1, + used: 500, + capacity: 1000, + unbounded: 0, + trend: [ + { date: '2026-07-20', usedBytes: 400, writtenBytes: 120, releasedBytes: 20 }, + { date: '2026-07-21', usedBytes: 500, writtenBytes: 150, releasedBytes: 50 }, + ], + items: [], + }, + downloaders: { + total: 0, + online: 0, + activeTasks: 0, + totalSlots: 0, + availableSlots: 0, + downloadBps: 0, + uploadBps: 0, + items: [], + }, +} + +const growth: AdminDashboardGrowthStats = { + generatedAt: '2026-07-22T00:00:00.000Z', + from: '2026-07-20T00:00:00.000Z', + to: '2026-07-21T23:59:59.999Z', + timeZone: 'UTC', + coverage: completeCoverage, + summary: { + totalUsers: 42, + newUsers: { value: 4, previousValue: 2, change: 2, changePercent: 100 }, + activeUsers: { value: 18, previousValue: 16, change: 2, changePercent: 12.5 }, + verifiedUsers: 38, + bannedUsers: 1, + silentUsers: 23, + activeUserRate: 42.9, + silentUserRate: 54.8, + }, + userScaleTrend: [ + { date: '2026-07-20', newUsers: 2, totalUsers: 40 }, + { date: '2026-07-21', newUsers: 2, totalUsers: 42 }, + ], + activeUserTrend: [ + { date: '2026-07-20', dau: 3, wau: 8, mau: 16 }, + { date: '2026-07-21', dau: 4, wau: 9, mau: 18 }, + ], + userStatus: [ + { name: 'normal', value: 18, percent: 42.9 }, + { name: 'silent', value: 23, percent: 54.8 }, + { name: 'banned', value: 1, percent: 2.3 }, + ], + registrationSources: [ + { name: 'credential', value: 30, percent: 71.4 }, + { name: 'github', value: 12, percent: 28.6 }, + ], +} + +test('admin dashboard and analytics render chart geometry @desktop', async ({ page }) => { + await page.route('**/api/site/overview', (route) => route.fulfill({ json: overview })) + await page.route('**/api/site/licensing/status', (route) => + route.fulfill({ + json: { bound: true, active: true, edition: 'pro', license_id: 'test-license', features: ['analytics'] }, + }), + ) + await page.route('**/api/site/stats/growth**', (route) => route.fulfill({ json: growth })) + + await signInAsAdmin(page) + await page.goto('/admin/dashboard') + + await expect(page.locator('.recharts-line-curve')).toHaveCount(3) + await expect(page.locator('.recharts-area-area')).toHaveCount(1) + await expect(page.locator('.recharts-sector')).toHaveCount(6) + + await page.goto('/admin/analytics') + await expect(page.getByText('用户规模趋势')).toBeVisible() + + await expect(page.locator('.recharts-line-curve')).toHaveCount(1) + await expect(page.locator('.recharts-area-area')).toHaveCount(3) + await expect(page.locator('.recharts-sector')).toHaveCount(3) +}) diff --git a/scripts/backfill-admin-stats.ts b/scripts/backfill-admin-stats.ts index 183ca28a..e443cee9 100644 --- a/scripts/backfill-admin-stats.ts +++ b/scripts/backfill-admin-stats.ts @@ -83,6 +83,7 @@ interface ValidationSummary { requiredDimensionMismatchGroups: number lowerBoundRollups: number legacyRollupRows: number + incompatibleUserSnapshotRows: number counterExpectedBuckets: number counterCompletedBuckets: number counterMissingBuckets: number @@ -244,6 +245,7 @@ WHERE ctr.issued_at IS NULL ); ${purgeLegacyRollupsSql()} +${purgeIncompatibleUserSnapshotsSql()} ${purgeOrphanRollupsSql()} ${purgeOpenRollupsSql(now)} ${purgeCounterRollupsSql()} @@ -269,6 +271,12 @@ WHERE CASE WHEN json_valid(metadata) = 1 THEN ELSE 0 END = 0;` } +function purgeIncompatibleUserSnapshotsSql(): string { + return `DELETE FROM stats_rollups_hourly +WHERE bucket_start < COALESCE(${statisticsFirstFullHourMsSql}, ${MIN_VALID_TIMESTAMP_MS}) + AND metric_key IN ('${M.userInventory}', '${M.userActiveSnapshot}');` +} + function purgeOrphanRollupsSql(): string { return `DELETE FROM stats_rollups_hourly AS result WHERE result.metric_key <> 'stats.rollup_run' @@ -794,6 +802,11 @@ SELECT json_object( AND json_extract(metadata, '$.scope') IN ('counters', 'snapshots', 'full') AND json_extract(metadata, '$.quality') = 'exact' ELSE 0 END = 0 + ), + 'incompatibleUserSnapshotRows', ( + SELECT COUNT(*) FROM stats_rollups_hourly + WHERE bucket_start < COALESCE(${statisticsFirstFullHourMsSql}, ${MIN_VALID_TIMESTAMP_MS}) + AND metric_key IN ('${M.userInventory}', '${M.userActiveSnapshot}') ) ) AS summary;` @@ -1087,6 +1100,7 @@ export function assertBackfillValidation(summary: ValidationSummary): void { summary.requiredDimensionMismatchGroups > 0 || summary.lowerBoundRollups > 0 || summary.legacyRollupRows > 0 || + summary.incompatibleUserSnapshotRows > 0 || summary.counterMissingBuckets > 0 || summary.openCounterMarkers > 0 ) { @@ -1100,6 +1114,7 @@ export function assertBackfillValidation(summary: ValidationSummary): void { requiredDimensionMismatchGroups: summary.requiredDimensionMismatchGroups, lowerBoundRollups: summary.lowerBoundRollups, legacyRollupRows: summary.legacyRollupRows, + incompatibleUserSnapshotRows: summary.incompatibleUserSnapshotRows, counterMissingBuckets: summary.counterMissingBuckets, openCounterMarkers: summary.openCounterMarkers, })}`, diff --git a/server/adapters/repos/admin-stats-hourly.ts b/server/adapters/repos/admin-stats-hourly.ts index 1208414d..c861753e 100644 --- a/server/adapters/repos/admin-stats-hourly.ts +++ b/server/adapters/repos/admin-stats-hourly.ts @@ -149,6 +149,22 @@ export class AdminStatsHourlyReader { } } + async completeDayKeys(requiredScope: AdminStatsRollupScope): Promise> { + const markerBuckets = await this.markerBuckets(requiredScope) + const expectedByDay = new Map() + const completedByDay = new Map() + for (let at = this.queryFrom.getTime(); at < this.queryTo.getTime(); at += HOUR_MS) { + const day = this.dayKey(new Date(at)) + expectedByDay.set(day, (expectedByDay.get(day) ?? 0) + 1) + if (markerBuckets.has(at)) completedByDay.set(day, (completedByDay.get(day) ?? 0) + 1) + } + return new Set( + [...expectedByDay] + .filter(([day, expected]) => expected > 0 && completedByDay.get(day) === expected) + .map(([day]) => day), + ) + } + endExclusive(): Date { return this.queryTo } diff --git a/server/adapters/repos/admin-stats-rollup.integration.test.ts b/server/adapters/repos/admin-stats-rollup.integration.test.ts index 21585047..6b620fcf 100644 --- a/server/adapters/repos/admin-stats-rollup.integration.test.ts +++ b/server/adapters/repos/admin-stats-rollup.integration.test.ts @@ -527,6 +527,38 @@ describe('admin hourly stats rollup', () => { expect(await reader.coverage()).toMatchObject({ status: 'empty', completedBuckets: 0 }) }) + it('marks a day complete only when every requested hour has an exact marker', async () => { + const { db } = await createTestApp() + const firstHour = Date.parse('2026-07-10T10:00:00.000Z') + const secondHour = firstHour + 3_600_000 + const metadata = '{"version":3,"scope":"counters","quality":"exact"}' + await db.run(sql` + INSERT INTO stats_rollups_hourly + (id, bucket_start, org_id, metric_key, dimension_key, dimension_value, + count, bytes, unique_count, metadata, updated_at) + VALUES + ('complete-day-first', ${firstHour}, '', 'stats.rollup_run', '', '', 1, 0, 0, ${metadata}, ${firstHour}) + `) + const range = { + from: new Date(firstHour), + to: new Date(secondHour + 3_600_000 - 1), + timeZone: 'UTC' as const, + } + + const partialReader = new AdminStatsHourlyReader(db, range, new Date(secondHour + 7_200_000)) + expect(await partialReader.completeDayKeys('counters')).toEqual(new Set()) + + await db.run(sql` + INSERT INTO stats_rollups_hourly + (id, bucket_start, org_id, metric_key, dimension_key, dimension_value, + count, bytes, unique_count, metadata, updated_at) + VALUES + ('complete-day-second', ${secondHour}, '', 'stats.rollup_run', '', '', 1, 0, 0, ${metadata}, ${secondHour}) + `) + const completeReader = new AdminStatsHourlyReader(db, range, new Date(secondHour + 7_200_000)) + expect(await completeReader.completeDayKeys('counters')).toEqual(new Set(['2026-07-10'])) + }) + it('never exposes the current open hour, even if rollup rows already exist', async () => { const { db } = await createTestApp() const bucketStart = Date.parse('2026-07-10T10:00:00.000Z') diff --git a/server/adapters/repos/admin-stats.ts b/server/adapters/repos/admin-stats.ts index 3a14195b..c5b57692 100644 --- a/server/adapters/repos/admin-stats.ts +++ b/server/adapters/repos/admin-stats.ts @@ -75,6 +75,8 @@ async function getOverviewStatistics( storageLedgerOpening, topUsage, storageDataQuality, + counterDays, + snapshotDays, ] = await Promise.all([ getUserInventory(reader), getActiveUserSnapshot(reader), @@ -87,6 +89,8 @@ async function getOverviewStatistics( getStorageUsageLedgerOpening(db), getTopPersonalUsage(db, now), getStorageDataQuality(reader), + reader.completeDayKeys('counters'), + reader.completeDayKeys('snapshots'), ]) const dates = createDateBuckets(effective) const activeByDate = new Map(activeByDay.map((row) => [row.date, row])) @@ -111,19 +115,22 @@ async function getOverviewStatistics( }, trend: dates.map((date) => ({ date, - totalUsers: totalUsersByDay.get(date) ?? null, - activeUsers: activeByDate.get(date)?.mau ?? null, - newUsers: newUsersByDay.has(date) ? (newUsersByDay.get(date) ?? null) : 0, + totalUsers: snapshotDays.has(date) ? (totalUsersByDay.get(date) ?? null) : null, + activeUsers: snapshotDays.has(date) ? (activeByDate.get(date)?.mau ?? null) : null, + newUsers: counterDays.has(date) ? (newUsersByDay.get(date) ?? 0) : null, })), topUsage: exactUsage ? topUsage : [], }, storageTrend: dates.map((date) => { const changes = storageChangesByDay.get(date) const exactChanges = - storageChangesExactFrom !== null && date >= storageChangesExactFrom && changes?.exact !== false + counterDays.has(date) && + storageChangesExactFrom !== null && + date >= storageChangesExactFrom && + changes?.exact !== false return { date, - usedBytes: exactLedger ? (storageUsedByDay.get(date) ?? null) : null, + usedBytes: exactLedger && snapshotDays.has(date) ? (storageUsedByDay.get(date) ?? null) : null, writtenBytes: exactChanges ? (changes?.writtenBytes ?? 0) : null, releasedBytes: exactChanges ? (changes?.releasedBytes ?? 0) : null, } @@ -259,8 +266,6 @@ async function getDashboardOverviewStats( previousTraffic, sharing, previousSharing, - sharingDataQuality, - previousSharingDataQuality, dataQuality, coverage, comparisonCoverage, @@ -268,6 +273,9 @@ async function getDashboardOverviewStats( comparisonSnapshotCoverage, trafficLedgerComplete, previousTrafficLedgerComplete, + counterDays, + snapshotDays, + trafficFirstCompleteDay, ] = await Promise.all([ getUserInventory(reader), getSignupTotal(reader), @@ -279,8 +287,6 @@ async function getDashboardOverviewStats( getTrafficTotals(previousReader), getSharingEventTotals(reader), getSharingComparisonTotals(previousReader), - getSharingDataQuality(reader), - getSharingDataQuality(previousReader), getTransferDataQuality(reader, previousReader), reader.coverage('counters'), previousReader.coverage('counters'), @@ -288,6 +294,9 @@ async function getDashboardOverviewStats( previousReader.coverage('snapshots'), trafficLedgerCoversRange(db, effective), trafficLedgerCoversRange(db, previous), + reader.completeDayKeys('counters'), + reader.completeDayKeys('snapshots'), + trafficLedgerFirstCompleteDay(db), ]) const [trendNewUsers, activeByDay, storageUsedByDay, uploadByDay, downloadByDay, missingBytesByDay] = await Promise.all([ @@ -302,19 +311,21 @@ async function getDashboardOverviewStats( const missingBytes = missingBytesByDay.get(date) return { date, - newUsers: trendNewUsers.has(date) ? (trendNewUsers.get(date) ?? null) : 0, - activeUsers: activeByDay.get(date) ?? null, - storageUsedBytes: storageUsedByDay.get(date) ?? null, - uploadBytes: missingBytes?.upload ? null : (uploadByDay.get(date) ?? 0), - downloadBytes: !trafficLedgerComplete || missingBytes?.download ? null : (downloadByDay.get(date) ?? 0), + newUsers: counterDays.has(date) ? (trendNewUsers.get(date) ?? 0) : null, + activeUsers: snapshotDays.has(date) ? (activeByDay.get(date) ?? null) : null, + storageUsedBytes: snapshotDays.has(date) ? (storageUsedByDay.get(date) ?? null) : null, + uploadBytes: counterDays.has(date) && !missingBytes?.upload ? (uploadByDay.get(date) ?? 0) : null, + downloadBytes: + counterDays.has(date) && + trafficFirstCompleteDay !== null && + date >= trafficFirstCompleteDay && + !missingBytes?.download + ? (downloadByDay.get(date) ?? 0) + : null, } }) - const sharingComparable = - comparable(coverage, comparisonCoverage) && - hasExactSharingHistory(sharingDataQuality) && - hasExactSharingHistory(previousSharingDataQuality) - const exactSharing = hasExactSharingHistory(sharingDataQuality) - const exactPreviousSharing = hasExactSharingHistory(previousSharingDataQuality) + const currentCountersComplete = completeCoverage(coverage) + const previousCountersComplete = completeCoverage(comparisonCoverage) const validQuotaBytes = quotas && quotas.invalidQuotaSpaces === 0 ? quotas.quotaBytes : null const currentUploadBytes = dataQuality.missingUploadBytesEvents === 0 ? traffic.uploadBytes : null const previousUploadBytes = dataQuality.previousMissingUploadBytesEvents === 0 ? previousTraffic.uploadBytes : null @@ -334,7 +345,11 @@ async function getDashboardOverviewStats( dataQuality, totals: { users: users?.total ?? null, - newUsers: delta(newUsers, previousNewUsers, comparable(coverage, comparisonCoverage)), + newUsers: delta( + currentCountersComplete ? newUsers : null, + previousCountersComplete ? previousNewUsers : null, + currentCountersComplete && previousCountersComplete, + ), activeUsers: delta( activeUsers?.mau ?? null, previousActiveUsers?.mau ?? null, @@ -344,14 +359,26 @@ async function getDashboardOverviewStats( storageUsedBytes: quotas?.usedBytes ?? null, storageQuotaBytes: validQuotaBytes, storageUtilization: nullablePercent(quotas?.usedBytes ?? null, validQuotaBytes), - trafficBytes: delta(currentTrafficBytes, previousTrafficBytes, comparable(coverage, comparisonCoverage)), - uploadBytes: delta(currentUploadBytes, previousUploadBytes, comparable(coverage, comparisonCoverage)), - downloadBytes: delta(currentDownloadBytes, previousDownloadBytes, comparable(coverage, comparisonCoverage)), + trafficBytes: delta( + currentCountersComplete ? currentTrafficBytes : null, + previousCountersComplete ? previousTrafficBytes : null, + currentCountersComplete && previousCountersComplete, + ), + uploadBytes: delta( + currentCountersComplete ? currentUploadBytes : null, + previousCountersComplete ? previousUploadBytes : null, + currentCountersComplete && previousCountersComplete, + ), + downloadBytes: delta( + currentCountersComplete ? currentDownloadBytes : null, + previousCountersComplete ? previousDownloadBytes : null, + currentCountersComplete && previousCountersComplete, + ), activeShares: sharing.activeShares, shareDownloads: delta( - exactSharing ? sharing.downloads : null, - exactPreviousSharing ? previousSharing.downloads : null, - sharingComparable, + currentCountersComplete ? sharing.downloads : null, + previousCountersComplete ? previousSharing.downloads : null, + currentCountersComplete && previousCountersComplete, ), }, trends, @@ -455,6 +482,8 @@ async function getDashboardGrowthStats( comparisonCoverage, snapshotCoverage, comparisonSnapshotCoverage, + counterDays, + snapshotDays, ] = await Promise.all([ getUserInventory(reader), getSignupTotal(reader), @@ -468,19 +497,27 @@ async function getDashboardGrowthStats( previousReader.coverage('counters'), reader.coverage('snapshots'), previousReader.coverage('snapshots'), + reader.completeDayKeys('counters'), + reader.completeDayKeys('snapshots'), ]) const newUsersByDay = await getSignupsByDay(reader) const userScaleTrend = createDateBuckets(effective).map((date) => ({ date, - newUsers: newUsersByDay.has(date) ? (newUsersByDay.get(date) ?? null) : 0, - totalUsers: totalsByDay.get(date) ?? null, + newUsers: counterDays.has(date) ? (newUsersByDay.get(date) ?? 0) : null, + totalUsers: snapshotDays.has(date) ? (totalsByDay.get(date) ?? null) : null, })) + const currentCountersComplete = completeCoverage(coverage) + const previousCountersComplete = completeCoverage(comparisonCoverage) return { ...statsFrame(now, effective, coverage, comparisonCoverage, snapshotCoverage, comparisonSnapshotCoverage), summary: { totalUsers: users?.total ?? null, - newUsers: delta(newUsers, previousNewUsers, comparable(coverage, comparisonCoverage)), + newUsers: delta( + currentCountersComplete ? newUsers : null, + previousCountersComplete ? previousNewUsers : null, + currentCountersComplete && previousCountersComplete, + ), activeUsers: delta( activeUsers?.mau ?? null, previousActiveUsers?.mau ?? null, @@ -493,7 +530,9 @@ async function getDashboardGrowthStats( silentUserRate: nullablePercent(users?.silent ?? null, users?.total ?? null), }, userScaleTrend, - activeUserTrend: activeByDay, + activeUserTrend: activeByDay.map((row) => + snapshotDays.has(row.date) ? row : { ...row, dau: null, wau: null, mau: null }, + ), userStatus: users ? percentRows([ { name: 'normal', value: users.normal }, @@ -502,7 +541,7 @@ async function getDashboardGrowthStats( { name: 'silent', value: users.silent }, ]) : [], - registrationSources, + registrationSources: currentCountersComplete ? registrationSources : [], } } @@ -538,6 +577,8 @@ async function getDashboardStorageStats( snapshotCoverage, comparisonSnapshotCoverage, missingBytesByDay, + counterDays, + snapshotDays, ] = await Promise.all([ getQuotaTotals(reader), getStorageInventory(reader), @@ -561,15 +602,17 @@ async function getDashboardStorageStats( reader.coverage('snapshots'), previousReader.coverage('snapshots'), getMissingTransferBytesByDay(reader), + reader.completeDayKeys('counters'), + reader.completeDayKeys('snapshots'), ]) const exactUsage = storageDataQuality.usageDriftSpaces === null || storageDataQuality.usageDriftSpaces === 0 const exactLedger = storageDataQuality.ledgerDriftSpaces === null || storageDataQuality.ledgerDriftSpaces === 0 const storageTrend = createDateBuckets(effective).map((date) => { return { date, - usedBytes: exactLedger ? (storageUsedByDay.get(date) ?? null) : null, - newBytes: missingBytesByDay.get(date)?.upload ? null : (uploadsByDay.get(date) ?? 0), - newFiles: uploadFilesByDay.get(date) ?? 0, + usedBytes: exactLedger && snapshotDays.has(date) ? (storageUsedByDay.get(date) ?? null) : null, + newBytes: counterDays.has(date) && !missingBytesByDay.get(date)?.upload ? (uploadsByDay.get(date) ?? 0) : null, + newFiles: counterDays.has(date) ? (uploadFilesByDay.get(date) ?? 0) : null, } }) const coldFileBytes = inventory @@ -579,6 +622,8 @@ async function getDashboardStorageStats( ) : null const validQuotaBytes = quotas && quotas.invalidQuotaSpaces === 0 ? quotas.quotaBytes : null + const currentCountersComplete = completeCoverage(coverage) + const previousCountersComplete = completeCoverage(comparisonCoverage) return { ...statsFrame(now, effective, coverage, comparisonCoverage, snapshotCoverage, comparisonSnapshotCoverage), @@ -589,11 +634,17 @@ async function getDashboardStorageStats( fileCount: inventory?.files ?? null, trashFileCount: trashInventory?.files ?? null, trashBytes: trashInventory?.bytes ?? null, - newFiles: delta(newFiles, previousNewFiles, comparable(coverage, comparisonCoverage)), + newFiles: delta( + currentCountersComplete ? newFiles : null, + previousCountersComplete ? previousNewFiles : null, + currentCountersComplete && previousCountersComplete, + ), newBytes: delta( - transferDataQuality.missingUploadBytesEvents === 0 ? uploadBytes : null, - transferDataQuality.previousMissingUploadBytesEvents === 0 ? previousUploadBytes : null, - comparable(coverage, comparisonCoverage), + currentCountersComplete && transferDataQuality.missingUploadBytesEvents === 0 ? uploadBytes : null, + previousCountersComplete && transferDataQuality.previousMissingUploadBytesEvents === 0 + ? previousUploadBytes + : null, + currentCountersComplete && previousCountersComplete, ), coldFileBytes, storageUtilization: nullablePercent(exactUsage ? (quotas?.usedBytes ?? null) : null, validQuotaBytes), @@ -643,6 +694,8 @@ async function getDashboardTrafficStats( missingBytesByDay, trafficLedgerComplete, previousTrafficLedgerComplete, + counterDays, + trafficFirstCompleteDay, ] = await Promise.all([ getTrafficTotals(reader), getTrafficTotals(previousReader), @@ -664,6 +717,8 @@ async function getDashboardTrafficStats( getMissingTransferBytesByDay(reader), trafficLedgerCoversRange(db, effective), trafficLedgerCoversRange(db, previous), + reader.completeDayKeys('counters'), + trafficLedgerFirstCompleteDay(db), ]) const sourceRows = new Map() if (traffic.uploadBytes > 0 || traffic.uploadRequests > 0) { @@ -690,14 +745,23 @@ async function getDashboardTrafficStats( } const trafficTrend = createDateBuckets(effective).map((date) => ({ date, - uploadBytes: missingBytesByDay.get(date)?.upload ? null : (uploadByDay.get(date) ?? 0), + uploadBytes: counterDays.has(date) && !missingBytesByDay.get(date)?.upload ? (uploadByDay.get(date) ?? 0) : null, downloadBytes: - !trafficLedgerComplete || missingBytesByDay.get(date)?.download ? null : (downloadByDay.get(date) ?? 0), - requests: trafficLedgerComplete - ? (uploadRequestsByDay.get(date) ?? 0) + (downloadRequestsByDay.get(date) ?? 0) - : null, + counterDays.has(date) && + trafficFirstCompleteDay !== null && + date >= trafficFirstCompleteDay && + !missingBytesByDay.get(date)?.download + ? (downloadByDay.get(date) ?? 0) + : null, + requests: + counterDays.has(date) && trafficFirstCompleteDay !== null && date >= trafficFirstCompleteDay + ? (uploadRequestsByDay.get(date) ?? 0) + (downloadRequestsByDay.get(date) ?? 0) + : null, })) const successTrend = createDateBuckets(effective).map((date) => { + if (!counterDays.has(date)) { + return { date, uploadSuccessRate: null, downloadSuccessRate: null } + } const uploadSuccesses = uploadSuccessByDay.get(date) ?? 0 const uploadFailures = uploadFailureByDay.get(date) ?? 0 const uploadRequests = uploadSuccesses + uploadFailures @@ -708,7 +772,9 @@ async function getDashboardTrafficStats( date, uploadSuccessRate: uploadRequests > 0 ? percent(uploadSuccesses, uploadRequests) : null, downloadSuccessRate: - trafficLedgerComplete && downloadRequests > 0 ? percent(downloadSuccesses, downloadRequests) : null, + trafficFirstCompleteDay !== null && date >= trafficFirstCompleteDay && downloadRequests > 0 + ? percent(downloadSuccesses, downloadRequests) + : null, } }) const totalRequests = traffic.uploadRequests + traffic.downloadRequests @@ -716,39 +782,50 @@ async function getDashboardTrafficStats( const issuedDownloads = Math.max(0, traffic.downloadRequests - blockedDownloads) const exactCurrentBytes = trafficLedgerComplete && dataQuality.missingBytesEvents === 0 const exactPreviousBytes = previousTrafficLedgerComplete && dataQuality.previousMissingBytesEvents === 0 + const currentCountersComplete = completeCoverage(coverage) + const previousCountersComplete = completeCoverage(comparisonCoverage) return { ...statsFrame(now, effective, coverage, comparisonCoverage), dataQuality, summary: { totalBytes: delta( - exactCurrentBytes ? traffic.uploadBytes + traffic.downloadBytes : null, - exactPreviousBytes ? previousTraffic.uploadBytes + previousTraffic.downloadBytes : null, - comparable(coverage, comparisonCoverage), + currentCountersComplete && exactCurrentBytes ? traffic.uploadBytes + traffic.downloadBytes : null, + previousCountersComplete && exactPreviousBytes + ? previousTraffic.uploadBytes + previousTraffic.downloadBytes + : null, + currentCountersComplete && previousCountersComplete, ), requestCount: delta( - trafficLedgerComplete ? totalRequests : null, - previousTrafficLedgerComplete ? previousTraffic.uploadRequests + previousTraffic.downloadRequests : null, - comparable(coverage, comparisonCoverage), + currentCountersComplete && trafficLedgerComplete ? totalRequests : null, + previousCountersComplete && previousTrafficLedgerComplete + ? previousTraffic.uploadRequests + previousTraffic.downloadRequests + : null, + currentCountersComplete && previousCountersComplete, ), - issuedDownloads: trafficLedgerComplete ? issuedDownloads : null, - blockedDownloads: trafficLedgerComplete ? blockedDownloads : null, - downloadIssueSuccessRate: trafficLedgerComplete - ? nullablePercent(issuedDownloads, issuedDownloads + blockedDownloads) - : null, - peakDailyBytes: exactCurrentBytes - ? Math.max(0, ...trafficTrend.map((row) => (row.uploadBytes ?? 0) + (row.downloadBytes ?? 0))) - : null, + issuedDownloads: currentCountersComplete && trafficLedgerComplete ? issuedDownloads : null, + blockedDownloads: currentCountersComplete && trafficLedgerComplete ? blockedDownloads : null, + downloadIssueSuccessRate: + currentCountersComplete && trafficLedgerComplete + ? nullablePercent(issuedDownloads, issuedDownloads + blockedDownloads) + : null, + peakDailyBytes: + currentCountersComplete && exactCurrentBytes + ? Math.max(0, ...trafficTrend.map((row) => (row.uploadBytes ?? 0) + (row.downloadBytes ?? 0))) + : null, }, trafficTrend, - sourceBreakdown: exactCurrentBytes ? percentRows([...sourceRows.values()], (row) => row.bytes) : [], - issueStatus: trafficLedgerComplete - ? percentRows( - [...statusRows.entries()].map(([status, countValue]) => ({ status, name: status, value: countValue })), - ).map(({ name, value, percent: pct }) => ({ status: name, count: value, percent: pct })) - : [], + sourceBreakdown: + currentCountersComplete && exactCurrentBytes ? percentRows([...sourceRows.values()], (row) => row.bytes) : [], + issueStatus: + currentCountersComplete && trafficLedgerComplete + ? percentRows( + [...statusRows.entries()].map(([status, countValue]) => ({ status, name: status, value: countValue })), + ).map(({ name, value, percent: pct }) => ({ status: name, count: value, percent: pct })) + : [], successTrend, - failureReasons: trafficLedgerComplete ? percentRows([...failureReasonRows.values()]) : [], + failureReasons: + currentCountersComplete && trafficLedgerComplete ? percentRows([...failureReasonRows.values()]) : [], } } @@ -771,12 +848,12 @@ async function getDashboardSharingStats( previousSaveCount, downloadSources, dataQuality, - previousDataQuality, coverage, comparisonCoverage, snapshotCoverage, comparisonSnapshotCoverage, shareCounters, + counterDays, ] = await Promise.all([ getSharingEventTotals(reader), getSharingComparisonTotals(previousReader), @@ -787,26 +864,25 @@ async function getDashboardSharingStats( getActivityMetricTotal(previousReader, metricSpec(['save_from_share']), 'count'), getActivityMetricDimensionTotals(reader, metricSpec(['share_download']), 'source', 'count'), getSharingDataQuality(reader), - getSharingDataQuality(previousReader), reader.coverage('counters'), previousReader.coverage('counters'), reader.coverage('snapshots'), previousReader.coverage('snapshots'), getLiveShareCounters(db), + reader.completeDayKeys('counters'), ]) const landingDownloads = downloadSources.get('landing_share') ?? 0 const directDownloads = downloadSources.get('direct_share') ?? 0 - const exactSharing = hasExactSharingHistory(dataQuality) - const exactPreviousSharing = hasExactSharingHistory(previousDataQuality) - const sharingComparable = comparable(coverage, comparisonCoverage) && exactSharing && exactPreviousSharing + const currentCountersComplete = completeCoverage(coverage) + const previousCountersComplete = completeCoverage(comparisonCoverage) const [downloadsByDay, savesByDay] = await Promise.all([ getActivityMetricByDay(reader, metricSpec(['share_download']), 'count'), getActivityMetricByDay(reader, metricSpec(['save_from_share']), 'count'), ]) const trend = createDateBuckets(effective).map((date) => ({ date, - downloads: exactSharing ? (downloadsByDay.get(date) ?? 0) : null, - saves: savesByDay.get(date) ?? 0, + downloads: counterDays.has(date) ? (downloadsByDay.get(date) ?? 0) : null, + saves: counterDays.has(date) ? (savesByDay.get(date) ?? 0) : null, })) const topShares = await getTopSharesWithPercent(db, shareCounters) @@ -815,18 +891,28 @@ async function getDashboardSharingStats( dataQuality, summary: { activeShares: sharing.activeShares, - createdShares: delta(createdInRange, createdPrevious, comparable(coverage, comparisonCoverage)), + createdShares: delta( + currentCountersComplete ? createdInRange : null, + previousCountersComplete ? createdPrevious : null, + currentCountersComplete && previousCountersComplete, + ), views: shareCounters.views, downloads: delta( - exactSharing ? sharing.downloads : null, - exactPreviousSharing ? previousSharing.downloads : null, - sharingComparable, + currentCountersComplete ? sharing.downloads : null, + previousCountersComplete ? previousSharing.downloads : null, + currentCountersComplete && previousCountersComplete, + ), + saves: delta( + currentCountersComplete ? saveCount : null, + previousCountersComplete ? previousSaveCount : null, + currentCountersComplete && previousCountersComplete, ), - saves: delta(saveCount, previousSaveCount, comparable(coverage, comparisonCoverage)), }, trend, - typeBreakdown: percentRows([...typeCounts.entries()].map(([name, value]) => ({ name, value }))), - sourceBreakdown: exactSharing + typeBreakdown: currentCountersComplete + ? percentRows([...typeCounts.entries()].map(([name, value]) => ({ name, value }))) + : [], + sourceBreakdown: currentCountersComplete ? percentRows([ { name: 'landing_share', value: landingDownloads }, { name: 'direct_share', value: directDownloads }, @@ -860,10 +946,6 @@ async function getSharingDataQuality(reader: AdminStatsHourlyReader): Promise= trafficLedgerExactFrom(opening) } +async function trafficLedgerFirstCompleteDay(db: Database): Promise { + const opening = await createCloudTrafficReportRepo(db).getLedgerOpening() + return opening ? fullDayFrom(trafficLedgerExactFrom(opening)) : null +} + function delta(value: number | null, previousValue: number | null, canCompare = true): AdminStatsDelta { if (value === null || previousValue === null || !canCompare) { return { value, previousValue: canCompare ? previousValue : null, change: null, changePercent: null } @@ -933,6 +1020,10 @@ function comparable(current: { status: string }, previous: { status: string }): return current.status === 'complete' && previous.status === 'complete' } +function completeCoverage(coverage: { status: string; quality: string }): boolean { + return coverage.status === 'complete' && coverage.quality === 'exact' +} + function createDateBuckets(range: AdminStatsDateRange): string[] { const dates = new Set() for (let timestamp = range.from.getTime(); timestamp <= range.to.getTime(); timestamp += 6 * 60 * 60 * 1000) { @@ -1296,7 +1387,10 @@ async function getStorageLedgerChangesByDay( function fullStorageChangeDayFrom(opening: Date | null): string | null { if (!opening) return null - const exactFrom = storageUsageLedgerExactFrom(opening) + return fullDayFrom(storageUsageLedgerExactFrom(opening)) +} + +function fullDayFrom(exactFrom: Date): string { const exactDay = dayKey(exactFrom) return exactFrom.getTime() === utcDateStart(exactDay).getTime() ? exactDay : addCalendarDays(exactDay, 1) } diff --git a/server/http/admin-stats.integration.test.ts b/server/http/admin-stats.integration.test.ts index 42abaa71..9bf6e361 100644 --- a/server/http/admin-stats.integration.test.ts +++ b/server/http/admin-stats.integration.test.ts @@ -176,7 +176,10 @@ describe('site stats routes', () => { ) `) - const res = await app.request('/api/site/stats/storage?from=2026-01-01&to=2026-01-01', { headers }) + const res = await app.request( + '/api/site/stats/storage?from=2026-01-01T00%3A00%3A00.000Z&to=2026-01-01T00%3A59%3A59.999Z&timeZone=UTC', + { headers }, + ) const body = (await res.json()) as { storageTrend: Array<{ date: string; usedBytes: number; newBytes: number; newFiles: number }> } @@ -232,6 +235,72 @@ describe('site stats routes', () => { expect(emptyBody.summary.totalBytes.value).toBe(0) }) + it('returns null instead of zero when requested hours are missing', async () => { + const { app, db } = await createTestApp() + const headers = await adminHeaders(app) + await seedProLicense(db) + const at = Date.UTC(2026, 6, 1, 10) + await db.run(sql` + INSERT INTO stats_rollups_hourly ( + id, bucket_start, org_id, metric_key, dimension_key, dimension_value, + count, bytes, unique_count, metadata, updated_at + ) VALUES + ('partial-marker', ${at}, '', 'stats.rollup_run', '', '', 1, 0, 0, + '{"version":3,"scope":"full","quality":"exact"}', ${at + 3_600_000}), + ('partial-signup', ${at}, '', 'user.signup', '', '', 2, 0, 0, + '{"version":3,"scope":"counters","quality":"exact"}', ${at + 3_600_000}), + ('partial-upload', ${at}, '', 'transfer.upload', '', '', 1, 128, 0, + '{"version":3,"scope":"counters","quality":"exact"}', ${at + 3_600_000}), + ('partial-share-save', ${at}, '', 'share.saved', '', '', 1, 0, 0, + '{"version":3,"scope":"counters","quality":"exact"}', ${at + 3_600_000}) + `) + const query = 'from=2026-07-01T10%3A00%3A00.000Z&to=2026-07-01T11%3A59%3A59.999Z&timeZone=UTC' + + const [growthRes, storageRes, trafficRes, sharingRes] = await Promise.all([ + app.request(`/api/site/stats/growth?${query}`, { headers }), + app.request(`/api/site/stats/storage?${query}`, { headers }), + app.request(`/api/site/stats/traffic?${query}`, { headers }), + app.request(`/api/site/stats/sharing?${query}`, { headers }), + ]) + const growth = (await growthRes.json()) as { + coverage: { status: string } + summary: { newUsers: { value: number | null } } + userScaleTrend: Array<{ newUsers: number | null }> + registrationSources: unknown[] + } + const storage = (await storageRes.json()) as { + summary: { newBytes: { value: number | null }; newFiles: { value: number | null } } + storageTrend: Array<{ newBytes: number | null; newFiles: number | null }> + } + const traffic = (await trafficRes.json()) as { + summary: { totalBytes: { value: number | null }; requestCount: { value: number | null } } + trafficTrend: Array<{ uploadBytes: number | null; requests: number | null }> + } + const sharing = (await sharingRes.json()) as { + summary: { createdShares: { value: number | null }; saves: { value: number | null } } + trend: Array<{ downloads: number | null; saves: number | null }> + typeBreakdown: unknown[] + } + + expect([growthRes.status, storageRes.status, trafficRes.status, sharingRes.status]).toEqual([200, 200, 200, 200]) + expect(growth.coverage.status).toBe('partial') + expect(growth.summary.newUsers.value).toBeNull() + expect(growth.userScaleTrend).toEqual([{ date: '2026-07-01', newUsers: null, totalUsers: null }]) + expect(growth.registrationSources).toEqual([]) + expect(storage.summary.newBytes.value).toBeNull() + expect(storage.summary.newFiles.value).toBeNull() + expect(storage.storageTrend).toEqual([{ date: '2026-07-01', usedBytes: null, newBytes: null, newFiles: null }]) + expect(traffic.summary.totalBytes.value).toBeNull() + expect(traffic.summary.requestCount.value).toBeNull() + expect(traffic.trafficTrend).toEqual([ + { date: '2026-07-01', uploadBytes: null, downloadBytes: null, requests: null }, + ]) + expect(sharing.summary.createdShares.value).toBeNull() + expect(sharing.summary.saves.value).toBeNull() + expect(sharing.trend).toEqual([{ date: '2026-07-01', downloads: null, saves: null }]) + expect(sharing.typeBreakdown).toEqual([]) + }) + it('hydrates completed-hour dashboard dimensions from rollups', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) @@ -338,9 +407,9 @@ describe('site stats routes', () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) await seedProLicense(db) - await seedStatsFixture(db) + const { bucketStart } = await seedStatsFixture(db) - const res = await app.request('/api/site/stats/growth', { headers }) + const res = await app.request(`/api/site/stats/growth?${exactHourQuery(bucketStart)}`, { headers }) const body = (await res.json()) as { summary: { totalUsers: number @@ -389,7 +458,10 @@ describe('site stats routes', () => { '{"version":3,"scope":"counters","quality":"exact"}', ${at + 3_600_000}) `) - const res = await app.request('/api/site/stats/growth?from=2026-01-01&to=2026-01-01', { headers }) + const res = await app.request( + '/api/site/stats/growth?from=2026-01-01T10%3A00%3A00.000Z&to=2026-01-01T10%3A59%3A59.999Z&timeZone=UTC', + { headers }, + ) const body = (await res.json()) as { summary: { newUsers: { value: number } } userScaleTrend: Array<{ newUsers: number; totalUsers: number }> @@ -630,7 +702,7 @@ describe('site stats routes', () => { `) await rebuildAdminStatsHour(db, bucketStart, new Date()) - const current = await app.request('/api/site/stats/traffic', { headers }) + const current = await app.request(`/api/site/stats/traffic?${exactHourQuery(bucketStart)}`, { headers }) const currentBody = (await current.json()) as { summary: { requestCount: { changePercent: number | null } } successTrend: Array<{ uploadSuccessRate: number | null }> @@ -662,10 +734,11 @@ describe('site stats routes', () => { await rebuildAdminStatsHour(db, new Date('2026-07-01T12:00:00.000Z'), new Date()) await rebuildAdminStatsHour(db, new Date('2026-06-30T12:00:00.000Z'), new Date()) + const query = 'from=2026-07-01T12%3A00%3A00.000Z&to=2026-07-01T12%3A59%3A59.999Z&timeZone=UTC' const [res, trafficRes, storageRes] = await Promise.all([ - app.request('/api/site/stats/overview?from=2026-07-01&to=2026-07-01', { headers }), - app.request('/api/site/stats/traffic?from=2026-07-01&to=2026-07-01', { headers }), - app.request('/api/site/stats/storage?from=2026-07-01&to=2026-07-01', { headers }), + app.request(`/api/site/stats/overview?${query}`, { headers }), + app.request(`/api/site/stats/traffic?${query}`, { headers }), + app.request(`/api/site/stats/storage?${query}`, { headers }), ]) const body = (await res.json()) as { dataQuality: AdminDashboardOverviewStats['dataQuality'] @@ -725,9 +798,9 @@ describe('site stats routes', () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) await seedProLicense(db) - await seedStatsFixture(db) + const { bucketStart } = await seedStatsFixture(db) - const res = await app.request('/api/site/stats/traffic', { headers }) + const res = await app.request(`/api/site/stats/traffic?${exactHourQuery(bucketStart)}`, { headers }) const body = (await res.json()) as { summary: { totalBytes: { value: number } @@ -1052,3 +1125,8 @@ async function seedStatsFixture(db: Awaited>['d await rebuildAdminStatsHour(db, bucketStart, new Date(generatedAt)) return { orgId, userId, bucketStart, eventMs: now, eventSec: nowSec } } + +function exactHourQuery(bucketStart: Date): string { + const end = new Date(bucketStart.getTime() + 3_600_000 - 1) + return `from=${encodeURIComponent(bucketStart.toISOString())}&to=${encodeURIComponent(end.toISOString())}&timeZone=UTC` +} diff --git a/server/scripts/backfill-admin-stats.test.ts b/server/scripts/backfill-admin-stats.test.ts index 0c1e5e37..6c06dc8a 100644 --- a/server/scripts/backfill-admin-stats.test.ts +++ b/server/scripts/backfill-admin-stats.test.ts @@ -191,6 +191,8 @@ describe('admin stats backfill', () => { ('snapshot-gauge', ${eventHourMs}, '', 'storage.used', '', '', 0, 512, 0, '{"version":3,"scope":"snapshots","quality":"exact","observedAt":"${snapshotObservedAt}"}', ${eventMs}), ('orphan-snapshot-gauge', ${eventHourMs - 3_600_000}, '', 'storage.used', '', '', 0, 256, 0, + '{"version":3,"scope":"snapshots","quality":"exact","observedAt":"${snapshotObservedAt}"}', ${eventMs}), + ('preopening-user-activity', ${historyStartMs - 3_600_000}, '', 'user.active_snapshot', 'window', 'mau', 57, 0, 0, '{"version":3,"scope":"snapshots","quality":"exact","observedAt":"${snapshotObservedAt}"}', ${eventMs}); `) @@ -256,6 +258,7 @@ describe('admin stats backfill', () => { rawActiveShares: 1, validActiveShares: 1, legacyRollupRows: 0, + incompatibleUserSnapshotRows: 0, counterExpectedBuckets: expectedBuckets, counterCompletedBuckets: expectedBuckets, counterMissingBuckets: 0, @@ -331,6 +334,9 @@ describe('admin stats backfill', () => { expect( db.prepare("SELECT COUNT(*) AS value FROM stats_rollups_hourly WHERE id = 'orphan-snapshot-gauge'").get(), ).toEqual({ value: 0 }) + expect( + db.prepare("SELECT COUNT(*) AS value FROM stats_rollups_hourly WHERE id = 'preopening-user-activity'").get(), + ).toEqual({ value: 0 }) expect( db.prepare('SELECT COUNT(*) AS value FROM stats_rollups_hourly WHERE bucket_start >= ?').get(currentHourMs), ).toEqual({ value: 0 }) diff --git a/src/routes/_authenticated/admin/analytics.test.tsx b/src/routes/_authenticated/admin/analytics.test.tsx index 5fc4d567..0380f87d 100644 --- a/src/routes/_authenticated/admin/analytics.test.tsx +++ b/src/routes/_authenticated/admin/analytics.test.tsx @@ -173,7 +173,7 @@ describe('Admin overview dashboard', () => { expect(screen.queryByText(/部分小时|数据下限|当前 700\/700|快照采样/)).toBeNull() }) - it('shows no module data when the selected range is incomplete', async () => { + it('renders available metrics when the selected range is incomplete', async () => { vi.mocked(useEntitlement).mockReturnValue({ bound: true, active: true, @@ -197,7 +197,8 @@ describe('Admin overview dashboard', () => { renderOverviewPage() - expect(await screen.findByText('暂无统计数据')).toBeTruthy() - expect(screen.queryByText('总用户数')).toBeNull() + expect(await screen.findByText('总用户数')).toBeTruthy() + expect(screen.getAllByText('暂无统计数据').length).toBeGreaterThan(0) + expect(screen.queryByText(/部分小时|数据下限|当前 699\/700|快照采样/)).toBeNull() }) }) diff --git a/src/routes/_authenticated/admin/analytics.tsx b/src/routes/_authenticated/admin/analytics.tsx index 874b5845..03ab46b2 100644 --- a/src/routes/_authenticated/admin/analytics.tsx +++ b/src/routes/_authenticated/admin/analytics.tsx @@ -399,67 +399,75 @@ function GrowthSection({ stats }: { stats: AdminDashboardGrowthStats }) {
- - - - - - - - - - - + {hasNumericData(stats.userScaleTrend, ['newUsers', 'totalUsers']) ? ( + + + + + + + + + + + + ) : ( + + )} - - - - - - - - - - - - + {hasNumericData(stats.activeUserTrend, ['dau', 'wau', 'mau']) ? ( + + + + + + + + + + + + + ) : ( + + )}
@@ -557,60 +565,64 @@ function StorageSection({ stats }: { stats: AdminDashboardStorageStats }) {
- - - - - - - - - - - - - + {hasNumericData(stats.storageTrend, ['usedBytes', 'newBytes', 'newFiles']) ? ( + + + + + + + + + + + + + + ) : ( + + )}
- - - - - - - labelize(String(value))} - /> - - - - - + {hasNumericData(stats.sizeBreakdown, ['files', 'bytes']) ? ( + + + + + + + labelize(String(value))} + /> + + + + + + ) : ( + + )} - - - - - - - name === '容量' ? [formatSize(Number(value)), name] : [formatNumber(Number(value)), name] - } - labelFormatter={(value) => labelize(String(value))} - /> - - - - - + {hasNumericData(stats.ageBreakdown, ['bytes']) ? ( + + + + + + + name === '容量' ? [formatSize(Number(value)), name] : [formatNumber(Number(value)), name] + } + labelFormatter={(value) => labelize(String(value))} + /> + + + + + + ) : ( + + )}
@@ -742,54 +762,58 @@ function TrafficSection({ stats }: { stats: AdminDashboardTrafficStats }) { title="传输事件趋势" subtitle="上传为确认完成字节,下载为链接签发对象字节,不代表客户端实际完成传输。" > - - - - - - - - - - - - - + {hasNumericData(stats.trafficTrend, ['uploadBytes', 'downloadBytes', 'requests']) ? ( + + + + + + + + + + + + + + ) : ( + + )} - - - - - `${value}%`} - tickLine={false} - axisLine={false} - fontSize={12} - width={46} - /> - [`${Number(value).toFixed(1)}%`, name]} - /> - - - - - + {hasNumericData(stats.successTrend, ['uploadSuccessRate', 'downloadSuccessRate']) ? ( + + + + + `${value}%`} + tickLine={false} + axisLine={false} + fontSize={12} + width={46} + /> + [`${Number(value).toFixed(1)}%`, name]} + /> + + + + + + ) : ( + + )} {stats.failureReasons.length === 0 ? ( @@ -938,35 +966,39 @@ function SharingSection({ stats }: { stats: AdminDashboardSharingStats }) {
- - - - - - - - - - - + {hasNumericData(stats.trend, ['downloads', 'saves']) ? ( + + + + + + + + + + + + ) : ( + + )}
@@ -1059,38 +1091,42 @@ function BreakdownChart({ }) { return ( -
- - - - {rows.map((row, index) => ( - - ))} - - valueFormatter(Number(value))} - /> - - - ({ - name: labelize(row.name), - percent: row.percent, - valueLabel: valueFormatter(row.value), - fill: CHART_COLORS[index % CHART_COLORS.length], - }))} - /> -
+ {hasPositiveBreakdown(rows) ? ( +
+ + + + {rows.map((row, index) => ( + + ))} + + valueFormatter(Number(value))} + /> + + + ({ + name: labelize(row.name), + percent: row.percent, + valueLabel: valueFormatter(row.value), + fill: CHART_COLORS[index % CHART_COLORS.length], + }))} + /> +
+ ) : ( + + )}
) } @@ -1106,33 +1142,37 @@ function BarBreakdownChart({ }) { return ( - - - - - - valueFormatter(Number(value))} - labelFormatter={(value) => labelize(String(value))} - /> - - {rows.map((row, index) => ( - - ))} - - - - + {hasPositiveBreakdown(rows) ? ( + + + + + + valueFormatter(Number(value))} + labelFormatter={(value) => labelize(String(value))} + /> + + {rows.map((row, index) => ( + + ))} + + + + + ) : ( + + )} ) } @@ -1216,7 +1256,6 @@ function QueryState({
) if (!query.data) return - if (query.data.coverage.status !== 'complete') return return children(query.data) } @@ -1235,12 +1274,20 @@ function SectionSkeleton() { function EmptyState() { return ( -
+
暂无统计数据
) } +function hasNumericData(rows: readonly T[], keys: readonly (keyof T)[]): boolean { + return rows.some((row) => keys.some((key) => typeof row[key] === 'number' && Number.isFinite(row[key]))) +} + +function hasPositiveBreakdown(rows: Array<{ value: number }>): boolean { + return rows.some((row) => row.value > 0) +} + function dateRangePresets(): Array<{ label: string; range: DateRange }> { const today = utcCalendarDate(new Date()) return [