fix(stats): preserve missing data in dashboard charts

This commit is contained in:
saltbo
2026-07-22 00:45:17 -04:00
parent 498ce73261
commit e79e03d8fa
9 changed files with 859 additions and 462 deletions
+108
View File
@@ -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)
})
+15
View File
@@ -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,
})}`,
@@ -149,6 +149,22 @@ export class AdminStatsHourlyReader {
}
}
async completeDayKeys(requiredScope: AdminStatsRollupScope): Promise<Set<string>> {
const markerBuckets = await this.markerBuckets(requiredScope)
const expectedByDay = new Map<string, number>()
const completedByDay = new Map<string, number>()
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
}
@@ -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')
+179 -85
View File
@@ -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<string, { name: string; bytes: number; requests: number }>()
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<Ad
return { unlocatedDownloads }
}
function hasExactSharingHistory(quality: AdminSharingDataQuality): boolean {
return quality.unlocatedDownloads === 0
}
function statsFrame(
now: Date,
range: AdminStatsDateRange,
@@ -917,6 +999,11 @@ async function trafficLedgerCoversRange(db: Database, range: AdminStatsDateRange
return opening !== null && range.from >= trafficLedgerExactFrom(opening)
}
async function trafficLedgerFirstCompleteDay(db: Database): Promise<string | null> {
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<string>()
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)
}
+88 -10
View File
@@ -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<ReturnType<typeof createTestApp>>['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`
}
@@ -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 })
@@ -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()
})
})
+411 -364
View File
@@ -399,67 +399,75 @@ function GrowthSection({ stats }: { stats: AdminDashboardGrowthStats }) {
</div>
<div className="grid gap-4 xl:grid-cols-2">
<ChartCard title="用户规模趋势" subtitle="柱形看每日新增用户,折线看累计用户规模。">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={stats.userScaleTrend}>
<CartesianGrid stroke={CHART_GRID_COLOR} strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={12} tickFormatter={formatChartDate} />
<YAxis tickLine={false} axisLine={false} fontSize={12} width={48} tickFormatter={formatCompactNumber} />
<RechartsTooltip
contentStyle={tooltipContentStyle}
labelStyle={tooltipLabelStyle}
formatter={chartTooltipFormatter}
/>
<Legend iconType="circle" wrapperStyle={{ fontSize: 12 }} />
<Bar dataKey="newUsers" name="新增用户" fill={CHART_COLORS[1]} radius={[4, 4, 0, 0]} />
<Line
type="monotone"
dataKey="totalUsers"
name="累计用户"
stroke={CHART_COLORS[0]}
strokeWidth={2}
dot={false}
/>
</ComposedChart>
</ResponsiveContainer>
{hasNumericData(stats.userScaleTrend, ['newUsers', 'totalUsers']) ? (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={stats.userScaleTrend}>
<CartesianGrid stroke={CHART_GRID_COLOR} strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={12} tickFormatter={formatChartDate} />
<YAxis tickLine={false} axisLine={false} fontSize={12} width={48} tickFormatter={formatCompactNumber} />
<RechartsTooltip
contentStyle={tooltipContentStyle}
labelStyle={tooltipLabelStyle}
formatter={chartTooltipFormatter}
/>
<Legend iconType="circle" wrapperStyle={{ fontSize: 12 }} />
<Bar dataKey="newUsers" name="新增用户" fill={CHART_COLORS[1]} radius={[4, 4, 0, 0]} />
<Line
type="monotone"
dataKey="totalUsers"
name="累计用户"
stroke={CHART_COLORS[0]}
strokeWidth={2}
dot={false}
/>
</ComposedChart>
</ResponsiveContainer>
) : (
<EmptyState />
)}
</ChartCard>
<ChartCard title="活跃用户趋势" subtitle="DAU、WAU、MAU 同时展示,用于判断用户活跃基本盘。">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={stats.activeUserTrend}>
<CartesianGrid stroke={CHART_GRID_COLOR} strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={12} tickFormatter={formatChartDate} />
<YAxis tickLine={false} axisLine={false} fontSize={12} width={48} tickFormatter={formatCompactNumber} />
<RechartsTooltip
contentStyle={tooltipContentStyle}
labelStyle={tooltipLabelStyle}
formatter={chartTooltipFormatter}
/>
<Legend iconType="circle" wrapperStyle={{ fontSize: 12 }} />
<Area
type="monotone"
dataKey="mau"
name="MAU"
stroke={CHART_COLORS[5]}
fill={CHART_COLORS[5]}
fillOpacity={0.08}
/>
<Area
type="monotone"
dataKey="wau"
name="WAU"
stroke={CHART_COLORS[3]}
fill={CHART_COLORS[3]}
fillOpacity={0.12}
/>
<Area
type="monotone"
dataKey="dau"
name="DAU"
stroke={CHART_COLORS[0]}
fill={CHART_COLORS[0]}
fillOpacity={0.16}
/>
</AreaChart>
</ResponsiveContainer>
{hasNumericData(stats.activeUserTrend, ['dau', 'wau', 'mau']) ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={stats.activeUserTrend}>
<CartesianGrid stroke={CHART_GRID_COLOR} strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={12} tickFormatter={formatChartDate} />
<YAxis tickLine={false} axisLine={false} fontSize={12} width={48} tickFormatter={formatCompactNumber} />
<RechartsTooltip
contentStyle={tooltipContentStyle}
labelStyle={tooltipLabelStyle}
formatter={chartTooltipFormatter}
/>
<Legend iconType="circle" wrapperStyle={{ fontSize: 12 }} />
<Area
type="monotone"
dataKey="mau"
name="MAU"
stroke={CHART_COLORS[5]}
fill={CHART_COLORS[5]}
fillOpacity={0.08}
/>
<Area
type="monotone"
dataKey="wau"
name="WAU"
stroke={CHART_COLORS[3]}
fill={CHART_COLORS[3]}
fillOpacity={0.12}
/>
<Area
type="monotone"
dataKey="dau"
name="DAU"
stroke={CHART_COLORS[0]}
fill={CHART_COLORS[0]}
fillOpacity={0.16}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<EmptyState />
)}
</ChartCard>
</div>
<div className="grid gap-4 xl:grid-cols-2">
@@ -557,60 +565,64 @@ function StorageSection({ stats }: { stats: AdminDashboardStorageStats }) {
</ChartCard>
<div className="grid gap-4 xl:grid-cols-2">
<ChartCard title="存储与写入趋势" subtitle="存储占用是水位;确认上传文件和字节是周期事件量,不代表净增长。">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={stats.storageTrend}>
<CartesianGrid stroke={CHART_GRID_COLOR} strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={12} tickFormatter={formatChartDate} />
<YAxis
yAxisId="bytes"
tickLine={false}
axisLine={false}
fontSize={12}
width={58}
tickFormatter={formatCompactSize}
/>
<YAxis
yAxisId="files"
orientation="right"
tickLine={false}
axisLine={false}
fontSize={12}
width={48}
tickFormatter={formatCompactNumber}
/>
<RechartsTooltip
contentStyle={tooltipContentStyle}
labelStyle={tooltipLabelStyle}
formatter={chartTooltipFormatter}
/>
<Legend iconType="circle" wrapperStyle={{ fontSize: 12 }} />
<Bar
yAxisId="files"
dataKey="newFiles"
name="确认上传文件"
fill={CHART_COLORS[1]}
radius={[4, 4, 0, 0]}
/>
<Line
yAxisId="bytes"
type="monotone"
dataKey="usedBytes"
name="存储占用"
stroke={CHART_COLORS[0]}
strokeWidth={2}
dot={false}
/>
<Line
yAxisId="bytes"
type="monotone"
dataKey="newBytes"
name="确认写入字节"
stroke={CHART_COLORS[2]}
strokeWidth={2}
dot={false}
/>
</ComposedChart>
</ResponsiveContainer>
{hasNumericData(stats.storageTrend, ['usedBytes', 'newBytes', 'newFiles']) ? (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={stats.storageTrend}>
<CartesianGrid stroke={CHART_GRID_COLOR} strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={12} tickFormatter={formatChartDate} />
<YAxis
yAxisId="bytes"
tickLine={false}
axisLine={false}
fontSize={12}
width={58}
tickFormatter={formatCompactSize}
/>
<YAxis
yAxisId="files"
orientation="right"
tickLine={false}
axisLine={false}
fontSize={12}
width={48}
tickFormatter={formatCompactNumber}
/>
<RechartsTooltip
contentStyle={tooltipContentStyle}
labelStyle={tooltipLabelStyle}
formatter={chartTooltipFormatter}
/>
<Legend iconType="circle" wrapperStyle={{ fontSize: 12 }} />
<Bar
yAxisId="files"
dataKey="newFiles"
name="确认上传文件"
fill={CHART_COLORS[1]}
radius={[4, 4, 0, 0]}
/>
<Line
yAxisId="bytes"
type="monotone"
dataKey="usedBytes"
name="存储占用"
stroke={CHART_COLORS[0]}
strokeWidth={2}
dot={false}
/>
<Line
yAxisId="bytes"
type="monotone"
dataKey="newBytes"
name="确认写入字节"
stroke={CHART_COLORS[2]}
strokeWidth={2}
dot={false}
/>
</ComposedChart>
</ResponsiveContainer>
) : (
<EmptyState />
)}
</ChartCard>
<BreakdownChart
title="文件类型容量占比"
@@ -620,74 +632,82 @@ function StorageSection({ stats }: { stats: AdminDashboardStorageStats }) {
</div>
<div className="grid gap-4 xl:grid-cols-2">
<ChartCard title="文件大小结构" subtitle="柱形看文件数量,折线看容量贡献,定位大对象压力。">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={stats.sizeBreakdown} margin={{ top: 8, right: 12, left: 0, bottom: 0 }}>
<CartesianGrid stroke={CHART_GRID_COLOR} strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="name" tickLine={false} axisLine={false} fontSize={12} tickFormatter={labelize} />
<YAxis
yAxisId="files"
tickLine={false}
axisLine={false}
fontSize={12}
width={46}
tickFormatter={formatCompactNumber}
/>
<YAxis
yAxisId="bytes"
orientation="right"
tickLine={false}
axisLine={false}
fontSize={12}
width={58}
tickFormatter={formatCompactSize}
/>
<RechartsTooltip
contentStyle={tooltipContentStyle}
labelStyle={tooltipLabelStyle}
formatter={chartTooltipFormatter}
labelFormatter={(value) => labelize(String(value))}
/>
<Legend iconType="circle" wrapperStyle={{ fontSize: 12 }} />
<Bar
yAxisId="files"
dataKey="files"
name="文件数"
fill={CHART_COLORS[5]}
radius={[4, 4, 0, 0]}
isAnimationActive={false}
/>
<Line
yAxisId="bytes"
type="monotone"
dataKey="bytes"
name="容量贡献"
stroke={CHART_COLORS[4]}
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
{hasNumericData(stats.sizeBreakdown, ['files', 'bytes']) ? (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={stats.sizeBreakdown} margin={{ top: 8, right: 12, left: 0, bottom: 0 }}>
<CartesianGrid stroke={CHART_GRID_COLOR} strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="name" tickLine={false} axisLine={false} fontSize={12} tickFormatter={labelize} />
<YAxis
yAxisId="files"
tickLine={false}
axisLine={false}
fontSize={12}
width={46}
tickFormatter={formatCompactNumber}
/>
<YAxis
yAxisId="bytes"
orientation="right"
tickLine={false}
axisLine={false}
fontSize={12}
width={58}
tickFormatter={formatCompactSize}
/>
<RechartsTooltip
contentStyle={tooltipContentStyle}
labelStyle={tooltipLabelStyle}
formatter={chartTooltipFormatter}
labelFormatter={(value) => labelize(String(value))}
/>
<Legend iconType="circle" wrapperStyle={{ fontSize: 12 }} />
<Bar
yAxisId="files"
dataKey="files"
name="文件数"
fill={CHART_COLORS[5]}
radius={[4, 4, 0, 0]}
isAnimationActive={false}
/>
<Line
yAxisId="bytes"
type="monotone"
dataKey="bytes"
name="容量贡献"
stroke={CHART_COLORS[4]}
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
) : (
<EmptyState />
)}
</ChartCard>
<ChartCard title="文件年龄分布" subtitle="仅按创建时间划分,不代表文件最近是否被访问。">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={stats.ageBreakdown} margin={{ top: 8, right: 12, left: 0, bottom: 0 }}>
<CartesianGrid stroke={CHART_GRID_COLOR} strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="name" tickLine={false} axisLine={false} fontSize={12} tickFormatter={labelize} />
<YAxis tickLine={false} axisLine={false} fontSize={12} width={58} tickFormatter={formatCompactSize} />
<RechartsTooltip
contentStyle={tooltipContentStyle}
labelStyle={tooltipLabelStyle}
formatter={(value, name) =>
name === '容量' ? [formatSize(Number(value)), name] : [formatNumber(Number(value)), name]
}
labelFormatter={(value) => labelize(String(value))}
/>
<Bar dataKey="bytes" name="容量" fill={CHART_COLORS[0]} radius={[4, 4, 0, 0]} isAnimationActive={false}>
<LabelList dataKey="percent" position="top" formatter={formatPercentLabel} fontSize={11} />
</Bar>
</BarChart>
</ResponsiveContainer>
{hasNumericData(stats.ageBreakdown, ['bytes']) ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={stats.ageBreakdown} margin={{ top: 8, right: 12, left: 0, bottom: 0 }}>
<CartesianGrid stroke={CHART_GRID_COLOR} strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="name" tickLine={false} axisLine={false} fontSize={12} tickFormatter={labelize} />
<YAxis tickLine={false} axisLine={false} fontSize={12} width={58} tickFormatter={formatCompactSize} />
<RechartsTooltip
contentStyle={tooltipContentStyle}
labelStyle={tooltipLabelStyle}
formatter={(value, name) =>
name === '容量' ? [formatSize(Number(value)), name] : [formatNumber(Number(value)), name]
}
labelFormatter={(value) => labelize(String(value))}
/>
<Bar dataKey="bytes" name="容量" fill={CHART_COLORS[0]} radius={[4, 4, 0, 0]} isAnimationActive={false}>
<LabelList dataKey="percent" position="top" formatter={formatPercentLabel} fontSize={11} />
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<EmptyState />
)}
</ChartCard>
</div>
</div>
@@ -742,54 +762,58 @@ function TrafficSection({ stats }: { stats: AdminDashboardTrafficStats }) {
title="传输事件趋势"
subtitle="上传为确认完成字节,下载为链接签发对象字节,不代表客户端实际完成传输。"
>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={stats.trafficTrend}>
<CartesianGrid stroke={CHART_GRID_COLOR} strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={12} tickFormatter={formatChartDate} />
<YAxis
yAxisId="bytes"
tickLine={false}
axisLine={false}
fontSize={12}
width={58}
tickFormatter={formatCompactSize}
/>
<YAxis
yAxisId="count"
orientation="right"
tickLine={false}
axisLine={false}
fontSize={12}
width={46}
tickFormatter={formatCompactNumber}
/>
<RechartsTooltip
contentStyle={tooltipContentStyle}
labelStyle={tooltipLabelStyle}
formatter={chartTooltipFormatter}
/>
<Legend iconType="circle" wrapperStyle={{ fontSize: 12 }} />
<Bar yAxisId="count" dataKey="requests" name="请求量" fill={CHART_COLORS[5]} radius={[4, 4, 0, 0]} />
<Line
yAxisId="bytes"
type="monotone"
dataKey="uploadBytes"
name="确认上传字节"
stroke={CHART_COLORS[0]}
strokeWidth={2}
dot={false}
/>
<Line
yAxisId="bytes"
type="monotone"
dataKey="downloadBytes"
name="下载签发字节"
stroke={CHART_COLORS[2]}
strokeWidth={2}
dot={false}
/>
</ComposedChart>
</ResponsiveContainer>
{hasNumericData(stats.trafficTrend, ['uploadBytes', 'downloadBytes', 'requests']) ? (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={stats.trafficTrend}>
<CartesianGrid stroke={CHART_GRID_COLOR} strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={12} tickFormatter={formatChartDate} />
<YAxis
yAxisId="bytes"
tickLine={false}
axisLine={false}
fontSize={12}
width={58}
tickFormatter={formatCompactSize}
/>
<YAxis
yAxisId="count"
orientation="right"
tickLine={false}
axisLine={false}
fontSize={12}
width={46}
tickFormatter={formatCompactNumber}
/>
<RechartsTooltip
contentStyle={tooltipContentStyle}
labelStyle={tooltipLabelStyle}
formatter={chartTooltipFormatter}
/>
<Legend iconType="circle" wrapperStyle={{ fontSize: 12 }} />
<Bar yAxisId="count" dataKey="requests" name="请求量" fill={CHART_COLORS[5]} radius={[4, 4, 0, 0]} />
<Line
yAxisId="bytes"
type="monotone"
dataKey="uploadBytes"
name="确认上传字节"
stroke={CHART_COLORS[0]}
strokeWidth={2}
dot={false}
/>
<Line
yAxisId="bytes"
type="monotone"
dataKey="downloadBytes"
name="下载签发字节"
stroke={CHART_COLORS[2]}
strokeWidth={2}
dot={false}
/>
</ComposedChart>
</ResponsiveContainer>
) : (
<EmptyState />
)}
</ChartCard>
<BreakdownChart
title="传输来源分布"
@@ -802,53 +826,57 @@ function TrafficSection({ stats }: { stats: AdminDashboardTrafficStats }) {
title="签发与确认成功率"
subtitle="上传成功指完成确认;下载成功指成功签发链接,不代表客户端下载完成。"
>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={stats.successTrend} margin={{ top: 8, right: 12, left: 0, bottom: 0 }}>
<CartesianGrid stroke={CHART_GRID_COLOR} strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
fontSize={12}
minTickGap={16}
tickFormatter={formatChartDate}
/>
<YAxis
domain={[0, 100]}
tickFormatter={(value) => `${value}%`}
tickLine={false}
axisLine={false}
fontSize={12}
width={46}
/>
<RechartsTooltip
contentStyle={tooltipContentStyle}
labelStyle={tooltipLabelStyle}
formatter={(value, name) => [`${Number(value).toFixed(1)}%`, name]}
/>
<Legend iconType="circle" wrapperStyle={{ fontSize: 12 }} />
<Area
type="monotone"
dataKey="uploadSuccessRate"
name="上传成功率"
stroke={CHART_COLORS[1]}
fill={CHART_COLORS[1]}
fillOpacity={0.08}
strokeWidth={2}
isAnimationActive={false}
/>
<Area
type="monotone"
dataKey="downloadSuccessRate"
name="下载成功率"
stroke={CHART_COLORS[0]}
fill={CHART_COLORS[0]}
fillOpacity={0.08}
strokeWidth={2}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
{hasNumericData(stats.successTrend, ['uploadSuccessRate', 'downloadSuccessRate']) ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={stats.successTrend} margin={{ top: 8, right: 12, left: 0, bottom: 0 }}>
<CartesianGrid stroke={CHART_GRID_COLOR} strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
fontSize={12}
minTickGap={16}
tickFormatter={formatChartDate}
/>
<YAxis
domain={[0, 100]}
tickFormatter={(value) => `${value}%`}
tickLine={false}
axisLine={false}
fontSize={12}
width={46}
/>
<RechartsTooltip
contentStyle={tooltipContentStyle}
labelStyle={tooltipLabelStyle}
formatter={(value, name) => [`${Number(value).toFixed(1)}%`, name]}
/>
<Legend iconType="circle" wrapperStyle={{ fontSize: 12 }} />
<Area
type="monotone"
dataKey="uploadSuccessRate"
name="上传成功率"
stroke={CHART_COLORS[1]}
fill={CHART_COLORS[1]}
fillOpacity={0.08}
strokeWidth={2}
isAnimationActive={false}
/>
<Area
type="monotone"
dataKey="downloadSuccessRate"
name="下载成功率"
stroke={CHART_COLORS[0]}
fill={CHART_COLORS[0]}
fillOpacity={0.08}
strokeWidth={2}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<EmptyState />
)}
</ChartCard>
<ChartCard title="失败原因分布" subtitle="分类比较用横向条形图,并标出每类占比。">
{stats.failureReasons.length === 0 ? (
@@ -938,35 +966,39 @@ function SharingSection({ stats }: { stats: AdminDashboardSharingStats }) {
</div>
<div className="grid gap-4">
<ChartCard title="分享下载与转存趋势" subtitle="按事件发生时间统计。">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={stats.trend}>
<CartesianGrid stroke={CHART_GRID_COLOR} strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={12} tickFormatter={formatChartDate} />
<YAxis tickLine={false} axisLine={false} fontSize={12} width={48} tickFormatter={formatCompactNumber} />
<RechartsTooltip
contentStyle={tooltipContentStyle}
labelStyle={tooltipLabelStyle}
formatter={chartTooltipFormatter}
/>
<Legend iconType="circle" wrapperStyle={{ fontSize: 12 }} />
<Area
type="monotone"
dataKey="downloads"
name="下载签发"
stroke={CHART_COLORS[2]}
fill={CHART_COLORS[2]}
fillOpacity={0.1}
/>
<Area
type="monotone"
dataKey="saves"
name="转存"
stroke={CHART_COLORS[3]}
fill={CHART_COLORS[3]}
fillOpacity={0.08}
/>
</AreaChart>
</ResponsiveContainer>
{hasNumericData(stats.trend, ['downloads', 'saves']) ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={stats.trend}>
<CartesianGrid stroke={CHART_GRID_COLOR} strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={12} tickFormatter={formatChartDate} />
<YAxis tickLine={false} axisLine={false} fontSize={12} width={48} tickFormatter={formatCompactNumber} />
<RechartsTooltip
contentStyle={tooltipContentStyle}
labelStyle={tooltipLabelStyle}
formatter={chartTooltipFormatter}
/>
<Legend iconType="circle" wrapperStyle={{ fontSize: 12 }} />
<Area
type="monotone"
dataKey="downloads"
name="下载签发"
stroke={CHART_COLORS[2]}
fill={CHART_COLORS[2]}
fillOpacity={0.1}
/>
<Area
type="monotone"
dataKey="saves"
name="转存"
stroke={CHART_COLORS[3]}
fill={CHART_COLORS[3]}
fillOpacity={0.08}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<EmptyState />
)}
</ChartCard>
</div>
<div className="grid gap-4 xl:grid-cols-2">
@@ -1059,38 +1091,42 @@ function BreakdownChart({
}) {
return (
<ChartCard title={title}>
<div className="grid h-full gap-4 lg:grid-cols-[minmax(0,1fr)_230px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={rows}
dataKey="value"
nameKey="name"
innerRadius="56%"
outerRadius="78%"
paddingAngle={2}
isAnimationActive={false}
>
{rows.map((row, index) => (
<Cell key={row.name} fill={CHART_COLORS[index % CHART_COLORS.length]} />
))}
</Pie>
<RechartsTooltip
contentStyle={tooltipContentStyle}
labelStyle={tooltipLabelStyle}
formatter={(value) => valueFormatter(Number(value))}
/>
</PieChart>
</ResponsiveContainer>
<PercentList
items={rows.map((row, index) => ({
name: labelize(row.name),
percent: row.percent,
valueLabel: valueFormatter(row.value),
fill: CHART_COLORS[index % CHART_COLORS.length],
}))}
/>
</div>
{hasPositiveBreakdown(rows) ? (
<div className="grid h-full gap-4 lg:grid-cols-[minmax(0,1fr)_230px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={rows}
dataKey="value"
nameKey="name"
innerRadius="56%"
outerRadius="78%"
paddingAngle={2}
isAnimationActive={false}
>
{rows.map((row, index) => (
<Cell key={row.name} fill={CHART_COLORS[index % CHART_COLORS.length]} />
))}
</Pie>
<RechartsTooltip
contentStyle={tooltipContentStyle}
labelStyle={tooltipLabelStyle}
formatter={(value) => valueFormatter(Number(value))}
/>
</PieChart>
</ResponsiveContainer>
<PercentList
items={rows.map((row, index) => ({
name: labelize(row.name),
percent: row.percent,
valueLabel: valueFormatter(row.value),
fill: CHART_COLORS[index % CHART_COLORS.length],
}))}
/>
</div>
) : (
<EmptyState />
)}
</ChartCard>
)
}
@@ -1106,33 +1142,37 @@ function BarBreakdownChart({
}) {
return (
<ChartCard title={title}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={rows} layout="vertical" margin={{ top: 8, right: 44, left: 8, bottom: 0 }}>
<CartesianGrid stroke={CHART_GRID_COLOR} strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" tickLine={false} axisLine={false} fontSize={12} tickFormatter={formatCompactNumber} />
<YAxis
type="category"
dataKey="name"
width={86}
tickLine={false}
axisLine={false}
fontSize={12}
tickFormatter={labelize}
/>
<RechartsTooltip
contentStyle={tooltipContentStyle}
labelStyle={tooltipLabelStyle}
formatter={(value) => valueFormatter(Number(value))}
labelFormatter={(value) => labelize(String(value))}
/>
<Bar dataKey="value" name="数量" radius={[0, 4, 4, 0]} isAnimationActive={false}>
{rows.map((row, index) => (
<Cell key={row.name} fill={CHART_COLORS[index % CHART_COLORS.length]} />
))}
<LabelList dataKey="percent" position="right" formatter={formatPercentLabel} fontSize={11} />
</Bar>
</BarChart>
</ResponsiveContainer>
{hasPositiveBreakdown(rows) ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={rows} layout="vertical" margin={{ top: 8, right: 44, left: 8, bottom: 0 }}>
<CartesianGrid stroke={CHART_GRID_COLOR} strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" tickLine={false} axisLine={false} fontSize={12} tickFormatter={formatCompactNumber} />
<YAxis
type="category"
dataKey="name"
width={86}
tickLine={false}
axisLine={false}
fontSize={12}
tickFormatter={labelize}
/>
<RechartsTooltip
contentStyle={tooltipContentStyle}
labelStyle={tooltipLabelStyle}
formatter={(value) => valueFormatter(Number(value))}
labelFormatter={(value) => labelize(String(value))}
/>
<Bar dataKey="value" name="数量" radius={[0, 4, 4, 0]} isAnimationActive={false}>
{rows.map((row, index) => (
<Cell key={row.name} fill={CHART_COLORS[index % CHART_COLORS.length]} />
))}
<LabelList dataKey="percent" position="right" formatter={formatPercentLabel} fontSize={11} />
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<EmptyState />
)}
</ChartCard>
)
}
@@ -1216,7 +1256,6 @@ function QueryState<T extends AdminStatsRange>({
</div>
)
if (!query.data) return <EmptyState />
if (query.data.coverage.status !== 'complete') return <EmptyState />
return children(query.data)
}
@@ -1235,12 +1274,20 @@ function SectionSkeleton() {
function EmptyState() {
return (
<div className="rounded-lg border border-dashed border-border/70 bg-muted/10 p-8 text-center text-sm text-muted-foreground">
<div className="flex h-full min-h-32 items-center justify-center rounded-lg border border-dashed border-border/70 bg-muted/10 p-8 text-center text-sm text-muted-foreground">
</div>
)
}
function hasNumericData<T extends object>(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 [