mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
fix(stats): preserve missing data in dashboard charts
This commit is contained in:
@@ -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)
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 [
|
||||
|
||||
Reference in New Issue
Block a user