mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
fix(admin): align dashboard stats data sources
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ import {
|
||||
orgQuotas,
|
||||
shares,
|
||||
siteInvitations,
|
||||
statsRollupsDaily,
|
||||
storages,
|
||||
webhookEvents,
|
||||
} from '../../db/schema'
|
||||
@@ -38,6 +39,10 @@ import type {
|
||||
const RUNNING_DOWNLOAD_STATUSES = ['queued', 'assigned', 'running', 'downloading', 'ingesting']
|
||||
const DOWNLOAD_ACTIVITY_ACTIONS = ['share_download', 'object_download']
|
||||
const AUDITED_DOWNLOAD_SOURCES = new Set(['landing_share', 'object_download'])
|
||||
const STORAGE_USED_ROLLUP_METRIC = 'storage.used.bytes'
|
||||
const GLOBAL_ROLLUP_ORG_ID = ''
|
||||
const GLOBAL_ROLLUP_DIMENSION_KEY = ''
|
||||
const GLOBAL_ROLLUP_DIMENSION_VALUE = ''
|
||||
|
||||
export function createAdminStatsRepo(db: Database): AdminStatsRepo {
|
||||
return {
|
||||
@@ -547,7 +552,7 @@ async function getDashboardOverviewStats(
|
||||
const [trendNewUsers, activeByDay, storageUsedByDay, uploadByDay, downloadByDay] = await Promise.all([
|
||||
getNewUsersByDay(db, range),
|
||||
getActiveUsersByDay(db, range),
|
||||
getStorageUsedByDay(db, range),
|
||||
getStorageUsedByDay(db, range, now),
|
||||
getActivityBytesByDay(db, range, ['upload_confirm']),
|
||||
getDownloadBytesByDay(db, range),
|
||||
])
|
||||
@@ -651,7 +656,7 @@ async function getDashboardStorageStats(
|
||||
.select({ id: matters.id, type: matters.type, size: matters.size, createdAt: matters.createdAt })
|
||||
.from(matters)
|
||||
.where(and(eq(matters.status, 'active'), eq(matters.dirtype, 0))),
|
||||
getStorageUsedByDay(db, range),
|
||||
getStorageUsedByDay(db, range, now),
|
||||
getStorageByType(db, range),
|
||||
])
|
||||
const fileItems = files.map((row) => ({ bytes: row.size ?? 0, createdAt: row.createdAt }))
|
||||
@@ -1083,18 +1088,94 @@ async function getActivityMetadataRows(db: Database, range: AdminStatsDateRange,
|
||||
)
|
||||
}
|
||||
|
||||
async function getStorageUsedByDay(db: Database, range: AdminStatsDateRange): Promise<Map<string, number>> {
|
||||
async function getStorageUsedByDay(db: Database, range: AdminStatsDateRange, now: Date): Promise<Map<string, number>> {
|
||||
const buckets = createDateBuckets(range)
|
||||
if (buckets.length === 0) return new Map()
|
||||
|
||||
const firstBucketStart = dateKeyStart(buckets[0])
|
||||
const lastBucketStart = dateKeyStart(buckets[buckets.length - 1])
|
||||
const rows = await db
|
||||
.select({
|
||||
bucketStart: statsRollupsDaily.bucketStart,
|
||||
bytes: statsRollupsDaily.bytes,
|
||||
})
|
||||
.from(statsRollupsDaily)
|
||||
.where(
|
||||
and(
|
||||
eq(statsRollupsDaily.metricKey, STORAGE_USED_ROLLUP_METRIC),
|
||||
eq(statsRollupsDaily.orgId, GLOBAL_ROLLUP_ORG_ID),
|
||||
eq(statsRollupsDaily.dimensionKey, GLOBAL_ROLLUP_DIMENSION_KEY),
|
||||
eq(statsRollupsDaily.dimensionValue, GLOBAL_ROLLUP_DIMENSION_VALUE),
|
||||
gte(statsRollupsDaily.bucketStart, firstBucketStart),
|
||||
lte(statsRollupsDaily.bucketStart, lastBucketStart),
|
||||
),
|
||||
)
|
||||
|
||||
const byDay = new Map(rows.map((row) => [dayKey(row.bucketStart), toNumber(row.bytes)]))
|
||||
const today = dayKey(now)
|
||||
const missingOrMutableBuckets = buckets.filter((date) => !byDay.has(date) || date === today)
|
||||
if (missingOrMutableBuckets.length === 0) return byDay
|
||||
|
||||
const computed = await computeStorageUsedByDayFromCurrentFiles(db, missingOrMutableBuckets)
|
||||
const updatedAt = now
|
||||
for (const date of missingOrMutableBuckets) {
|
||||
const bytes = computed.get(date) ?? 0
|
||||
byDay.set(date, bytes)
|
||||
const row = {
|
||||
id: storageUsedRollupId(date),
|
||||
bucketStart: dateKeyStart(date),
|
||||
orgId: GLOBAL_ROLLUP_ORG_ID,
|
||||
metricKey: STORAGE_USED_ROLLUP_METRIC,
|
||||
dimensionKey: GLOBAL_ROLLUP_DIMENSION_KEY,
|
||||
dimensionValue: GLOBAL_ROLLUP_DIMENSION_VALUE,
|
||||
count: 0,
|
||||
bytes,
|
||||
uniqueCount: 0,
|
||||
metadata: JSON.stringify({ source: 'current_state_backfill' }),
|
||||
updatedAt,
|
||||
}
|
||||
const insert = db.insert(statsRollupsDaily).values(row)
|
||||
if (date === today) {
|
||||
await insert.onConflictDoUpdate({
|
||||
target: [
|
||||
statsRollupsDaily.bucketStart,
|
||||
statsRollupsDaily.orgId,
|
||||
statsRollupsDaily.metricKey,
|
||||
statsRollupsDaily.dimensionKey,
|
||||
statsRollupsDaily.dimensionValue,
|
||||
],
|
||||
set: { bytes, metadata: row.metadata, updatedAt },
|
||||
})
|
||||
} else {
|
||||
await insert.onConflictDoNothing({
|
||||
target: [
|
||||
statsRollupsDaily.bucketStart,
|
||||
statsRollupsDaily.orgId,
|
||||
statsRollupsDaily.metricKey,
|
||||
statsRollupsDaily.dimensionKey,
|
||||
statsRollupsDaily.dimensionValue,
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return byDay
|
||||
}
|
||||
|
||||
async function computeStorageUsedByDayFromCurrentFiles(db: Database, buckets: string[]): Promise<Map<string, number>> {
|
||||
const sortedBuckets = [...buckets].sort()
|
||||
const lastDayEnd = endOfDateKey(sortedBuckets[sortedBuckets.length - 1])
|
||||
const files = await db
|
||||
.select({ size: matters.size, createdAt: matters.createdAt })
|
||||
.from(matters)
|
||||
.where(and(eq(matters.status, 'active'), eq(matters.dirtype, 0), lte(matters.createdAt, range.to)))
|
||||
.where(and(eq(matters.status, 'active'), eq(matters.dirtype, 0), lte(matters.createdAt, lastDayEnd)))
|
||||
const sortedFiles = files
|
||||
.map((row) => ({ bytes: row.size ?? 0, createdAt: row.createdAt }))
|
||||
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||
const byDay = new Map<string, number>()
|
||||
let cursor = 0
|
||||
let runningBytes = 0
|
||||
for (const date of createDateBuckets(range)) {
|
||||
for (const date of sortedBuckets) {
|
||||
const dayEnd = endOfDateKey(date)
|
||||
while (cursor < sortedFiles.length && sortedFiles[cursor].createdAt <= dayEnd) {
|
||||
runningBytes += sortedFiles[cursor].bytes
|
||||
@@ -1236,7 +1317,7 @@ async function getTopSharesByActivity(
|
||||
counts.set(row.targetId, item)
|
||||
}
|
||||
const topIds = [...counts.entries()]
|
||||
.sort((a, b) => b[1].views + b[1].downloads - (a[1].views + a[1].downloads))
|
||||
.sort((a, b) => b[1].views - a[1].views || b[1].downloads - a[1].downloads)
|
||||
.slice(0, 8)
|
||||
.map(([id]) => id)
|
||||
if (topIds.length === 0) return []
|
||||
@@ -1482,10 +1563,18 @@ function endOfDateKey(date: string): Date {
|
||||
return new Date(`${date}T23:59:59.999Z`)
|
||||
}
|
||||
|
||||
function dateKeyStart(date: string): Date {
|
||||
return new Date(`${date}T00:00:00.000Z`)
|
||||
}
|
||||
|
||||
function dayKey(date: Date): string {
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function storageUsedRollupId(date: string): string {
|
||||
return `daily:${STORAGE_USED_ROLLUP_METRIC}:${date}`
|
||||
}
|
||||
|
||||
function unixSeconds(date: Date): number {
|
||||
return Math.floor(date.getTime() / 1000)
|
||||
}
|
||||
|
||||
@@ -99,6 +99,42 @@ describe('admin stats routes', () => {
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('does not publish admin stats routes in the OpenAPI document', async () => {
|
||||
const { app } = await createTestApp()
|
||||
|
||||
const res = await app.request('/api/openapi.json')
|
||||
const body = (await res.json()) as { paths: Record<string, unknown> }
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(Object.keys(body.paths).some((path) => path.startsWith('/api/admin/stats'))).toBe(false)
|
||||
})
|
||||
|
||||
it('reads storage waterline trends from daily rollups when present', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
await seedProLicense(db)
|
||||
await seedStatsFixture(db)
|
||||
|
||||
await db.run(sql`
|
||||
INSERT INTO stats_rollups_daily (
|
||||
id, bucket_start, org_id, metric_key, dimension_key, dimension_value,
|
||||
count, bytes, unique_count, metadata, updated_at
|
||||
)
|
||||
VALUES (
|
||||
'storage-used-2026-01-01', ${Date.UTC(2026, 0, 1)}, '', 'storage.used.bytes', '', '',
|
||||
0, 4096, 0, NULL, ${Date.UTC(2026, 0, 2)}
|
||||
)
|
||||
`)
|
||||
|
||||
const res = await app.request('/api/admin/stats/storage?from=2026-01-01&to=2026-01-01', { headers })
|
||||
const body = (await res.json()) as {
|
||||
storageTrend: Array<{ date: string; usedBytes: number; newBytes: number; newFiles: number }>
|
||||
}
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(body.storageTrend).toEqual([{ date: '2026-01-01', usedBytes: 4096, newBytes: 0, newFiles: 0 }])
|
||||
})
|
||||
|
||||
it('returns traffic dashboard stats from audit-backed download events for Pro admins', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
@@ -191,6 +227,34 @@ describe('admin stats routes', () => {
|
||||
expect(oldRanking.storageByType).toEqual([])
|
||||
expect(oldRanking.topShares).toEqual([])
|
||||
})
|
||||
|
||||
it('orders top share rankings by views before downloads', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
await seedProLicense(db)
|
||||
const { orgId, userId } = await seedStatsFixture(db)
|
||||
const nowSec = Math.floor(Date.now() / 1000)
|
||||
const futureSec = nowSec + 7 * 24 * 60 * 60
|
||||
|
||||
await db.run(sql`
|
||||
INSERT INTO shares (id, token, kind, matter_id, org_id, creator_id, expires_at, download_limit, views, downloads, status, created_at)
|
||||
VALUES ('share-download-heavy', 'share-download-heavy', 'landing', 'stats-file', ${orgId}, ${userId}, ${futureSec}, 10, 0, 3, 'active', ${nowSec})
|
||||
`)
|
||||
await db.run(sql`
|
||||
INSERT INTO activity_events (id, org_id, user_id, actor_type, action, target_type, target_id, target_name, metadata, created_at)
|
||||
VALUES
|
||||
('activity-download-heavy-1', ${orgId}, NULL, 'anonymous', 'share_download', 'share', 'share-download-heavy', 'report.pdf', '{"bytes":512,"source":"landing_share","anonymous":true}', ${nowSec}),
|
||||
('activity-download-heavy-2', ${orgId}, NULL, 'anonymous', 'share_download', 'share', 'share-download-heavy', 'report.pdf', '{"bytes":512,"source":"landing_share","anonymous":true}', ${nowSec}),
|
||||
('activity-download-heavy-3', ${orgId}, NULL, 'anonymous', 'share_download', 'share', 'share-download-heavy', 'report.pdf', '{"bytes":512,"source":"landing_share","anonymous":true}', ${nowSec})
|
||||
`)
|
||||
|
||||
const res = await app.request('/api/admin/stats/ranking', { headers })
|
||||
const body = (await res.json()) as { topShares: Array<{ token: string; views: number; downloads: number }> }
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(body.topShares[0]).toMatchObject({ token: 'share-token-1', views: 1, downloads: 1 })
|
||||
expect(body.topShares[1]).toMatchObject({ token: 'share-download-heavy', views: 0, downloads: 3 })
|
||||
})
|
||||
})
|
||||
|
||||
async function seedStatsFixture(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
|
||||
|
||||
+12
-432
@@ -1,4 +1,6 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import { requireAdmin } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import { requireFeature } from '../middleware/require-feature'
|
||||
@@ -12,137 +14,6 @@ import {
|
||||
getAdminDashboardTrafficStats,
|
||||
getAdminDetailedStats,
|
||||
} from '../usecases/admin-stats'
|
||||
import { errorResponse, jsonContent } from './openapi'
|
||||
|
||||
const coreStatsSchema = z
|
||||
.object({
|
||||
generatedAt: z.string(),
|
||||
users: z.object({
|
||||
total: z.number().int(),
|
||||
admins: z.number().int(),
|
||||
activeLast30Days: z.number().int(),
|
||||
newLast7Days: z.number().int(),
|
||||
}),
|
||||
spaces: z.object({
|
||||
total: z.number().int(),
|
||||
personal: z.number().int(),
|
||||
team: z.number().int(),
|
||||
newLast30Days: z.number().int(),
|
||||
}),
|
||||
storage: z.object({
|
||||
usedBytes: z.number().int(),
|
||||
quotaBytes: z.number().int(),
|
||||
quotaUtilization: z.number(),
|
||||
capacityBytes: z.number().int(),
|
||||
backendCount: z.number().int(),
|
||||
activeBackendCount: z.number().int(),
|
||||
}),
|
||||
traffic: z.object({
|
||||
usedBytes: z.number().int(),
|
||||
quotaBytes: z.number().int(),
|
||||
utilization: z.number(),
|
||||
period: z.string(),
|
||||
}),
|
||||
sharing: z.object({
|
||||
totalShares: z.number().int(),
|
||||
activeShares: z.number().int(),
|
||||
views: z.number().int(),
|
||||
downloads: z.number().int(),
|
||||
}),
|
||||
operations: z.object({
|
||||
pendingInvitations: z.number().int(),
|
||||
failedBackgroundJobs: z.number().int(),
|
||||
offlineDownloaders: z.number().int(),
|
||||
runningDownloadTasks: z.number().int(),
|
||||
}),
|
||||
})
|
||||
.openapi('AdminCoreStats')
|
||||
|
||||
const statusCountSchema = z.object({ status: z.string(), count: z.number().int() })
|
||||
|
||||
const detailedStatsSchema = z
|
||||
.object({
|
||||
generatedAt: z.string(),
|
||||
periodDays: z.number().int(),
|
||||
trends: z.array(
|
||||
z.object({
|
||||
date: z.string(),
|
||||
signups: z.number().int(),
|
||||
activeUsers: z.number().int(),
|
||||
shareViews: z.number().int(),
|
||||
shareDownloads: z.number().int(),
|
||||
remoteTasks: z.number().int(),
|
||||
failedJobs: z.number().int(),
|
||||
}),
|
||||
),
|
||||
usageBySpace: z.array(
|
||||
z.object({
|
||||
orgId: z.string(),
|
||||
orgName: z.string(),
|
||||
orgType: z.string(),
|
||||
usedBytes: z.number().int(),
|
||||
quotaBytes: z.number().int(),
|
||||
utilization: z.number(),
|
||||
}),
|
||||
),
|
||||
storageByType: z.array(z.object({ type: z.string(), bytes: z.number().int(), files: z.number().int() })),
|
||||
topShares: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
token: z.string(),
|
||||
name: z.string(),
|
||||
creatorId: z.string(),
|
||||
creatorName: z.string(),
|
||||
views: z.number().int(),
|
||||
downloads: z.number().int(),
|
||||
status: z.string(),
|
||||
}),
|
||||
),
|
||||
sharing: z.object({
|
||||
expiredShares: z.number().int(),
|
||||
revokedShares: z.number().int(),
|
||||
downloadLimitHitShares: z.number().int(),
|
||||
conversionRate: z.number(),
|
||||
}),
|
||||
remoteDownloads: z.object({
|
||||
total: z.number().int(),
|
||||
completed: z.number().int(),
|
||||
failed: z.number().int(),
|
||||
running: z.number().int(),
|
||||
successRate: z.number(),
|
||||
byStatus: z.array(statusCountSchema),
|
||||
failureReasons: z.array(z.object({ reason: z.string(), count: z.number().int() })),
|
||||
byDownloader: z.array(
|
||||
z.object({
|
||||
downloaderId: z.string(),
|
||||
name: z.string(),
|
||||
status: z.string(),
|
||||
tasks: z.number().int(),
|
||||
failedTasks: z.number().int(),
|
||||
lastHeartbeatAt: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
reliability: z.object({
|
||||
backgroundJobs: z.object({
|
||||
total: z.number().int(),
|
||||
failed: z.number().int(),
|
||||
failureRate: z.number(),
|
||||
byStatus: z.array(statusCountSchema),
|
||||
failures: z.array(
|
||||
z.object({ id: z.string(), type: z.string(), errorMessage: z.string().nullable(), createdAt: z.string() }),
|
||||
),
|
||||
}),
|
||||
cloudTrafficReports: z.object({ pending: z.number().int(), failed: z.number().int() }),
|
||||
license: z.object({
|
||||
active: z.boolean(),
|
||||
edition: z.string().nullable(),
|
||||
lastRefreshAt: z.string().nullable(),
|
||||
lastRefreshError: z.string().nullable(),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.openapi('AdminDetailedStats')
|
||||
|
||||
const detailsQuerySchema = z.object({
|
||||
periodDays: z.coerce.number().int().min(7).max(90).default(30),
|
||||
@@ -157,180 +28,6 @@ const rangeQuerySchema = z.object({
|
||||
to: dashboardDateSchema.optional(),
|
||||
})
|
||||
|
||||
const statsRangeFields = {
|
||||
generatedAt: z.string(),
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
}
|
||||
|
||||
const deltaSchema = z.object({
|
||||
value: z.number(),
|
||||
previousValue: z.number(),
|
||||
changePercent: z.number(),
|
||||
})
|
||||
|
||||
const topShareWithPercentSchema = z.object({
|
||||
id: z.string(),
|
||||
token: z.string(),
|
||||
name: z.string(),
|
||||
creatorId: z.string(),
|
||||
creatorName: z.string(),
|
||||
views: z.number(),
|
||||
downloads: z.number(),
|
||||
status: z.string(),
|
||||
viewPercent: z.number(),
|
||||
downloadPercent: z.number(),
|
||||
})
|
||||
|
||||
const usageBySpaceSchema = z.object({
|
||||
orgId: z.string(),
|
||||
orgName: z.string(),
|
||||
orgType: z.string(),
|
||||
usedBytes: z.number(),
|
||||
quotaBytes: z.number(),
|
||||
utilization: z.number(),
|
||||
})
|
||||
|
||||
const storageByTypeSchema = z.object({
|
||||
type: z.string(),
|
||||
bytes: z.number(),
|
||||
files: z.number(),
|
||||
})
|
||||
|
||||
const namedPercentValueSchema = z.object({
|
||||
name: z.string(),
|
||||
value: z.number(),
|
||||
percent: z.number(),
|
||||
})
|
||||
|
||||
const namedBytesBreakdownSchema = z.object({
|
||||
name: z.string(),
|
||||
bytes: z.number(),
|
||||
files: z.number(),
|
||||
percent: z.number(),
|
||||
})
|
||||
|
||||
const dashboardOverviewStatsSchema = z
|
||||
.object({
|
||||
...statsRangeFields,
|
||||
totals: z.object({
|
||||
users: z.number(),
|
||||
newUsers: deltaSchema,
|
||||
activeUsers: deltaSchema,
|
||||
storageUsedBytes: z.number(),
|
||||
storageQuotaBytes: z.number(),
|
||||
trafficBytes: deltaSchema,
|
||||
uploadBytes: deltaSchema,
|
||||
downloadBytes: deltaSchema,
|
||||
activeShares: z.number(),
|
||||
shareViews: deltaSchema,
|
||||
shareDownloads: deltaSchema,
|
||||
}),
|
||||
trends: z.array(
|
||||
z.object({
|
||||
date: z.string(),
|
||||
newUsers: z.number(),
|
||||
activeUsers: z.number(),
|
||||
storageUsedBytes: z.number(),
|
||||
uploadBytes: z.number(),
|
||||
downloadBytes: z.number(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.openapi('AdminDashboardOverviewStats')
|
||||
|
||||
const dashboardGrowthStatsSchema = z
|
||||
.object({
|
||||
...statsRangeFields,
|
||||
summary: z.object({
|
||||
totalUsers: z.number(),
|
||||
newUsers: deltaSchema,
|
||||
activeUsers: deltaSchema,
|
||||
verifiedUsers: z.number(),
|
||||
bannedUsers: z.number(),
|
||||
silentUsers: z.number(),
|
||||
}),
|
||||
userScaleTrend: z.array(z.object({ date: z.string(), newUsers: z.number(), totalUsers: z.number() })),
|
||||
activeUserTrend: z.array(z.object({ date: z.string(), dau: z.number(), wau: z.number(), mau: z.number() })),
|
||||
userStatus: z.array(namedPercentValueSchema),
|
||||
registrationSources: z.array(namedPercentValueSchema),
|
||||
})
|
||||
.openapi('AdminDashboardGrowthStats')
|
||||
|
||||
const dashboardStorageStatsSchema = z
|
||||
.object({
|
||||
...statsRangeFields,
|
||||
summary: z.object({
|
||||
storageUsedBytes: z.number(),
|
||||
quotaBytes: z.number(),
|
||||
fileCount: z.number(),
|
||||
newFiles: deltaSchema,
|
||||
newBytes: deltaSchema,
|
||||
coldFileBytes: z.number(),
|
||||
}),
|
||||
storageTrend: z.array(
|
||||
z.object({ date: z.string(), usedBytes: z.number(), newBytes: z.number(), newFiles: z.number() }),
|
||||
),
|
||||
typeBreakdown: z.array(storageByTypeSchema.extend({ percent: z.number() })),
|
||||
sizeBreakdown: z.array(namedBytesBreakdownSchema),
|
||||
ageBreakdown: z.array(namedBytesBreakdownSchema),
|
||||
})
|
||||
.openapi('AdminDashboardStorageStats')
|
||||
|
||||
const dashboardTrafficStatsSchema = z
|
||||
.object({
|
||||
...statsRangeFields,
|
||||
summary: z.object({
|
||||
totalBytes: deltaSchema,
|
||||
requestCount: deltaSchema,
|
||||
issuedDownloads: z.number(),
|
||||
blockedDownloads: z.number(),
|
||||
issueRate: z.number(),
|
||||
peakDailyBytes: z.number(),
|
||||
}),
|
||||
trafficTrend: z.array(
|
||||
z.object({ date: z.string(), uploadBytes: z.number(), downloadBytes: z.number(), requests: z.number() }),
|
||||
),
|
||||
sourceBreakdown: z.array(
|
||||
z.object({ name: z.string(), bytes: z.number(), requests: z.number(), percent: z.number() }),
|
||||
),
|
||||
issueStatus: z.array(z.object({ status: z.string(), count: z.number(), percent: z.number() })),
|
||||
bandwidthTrend: z.array(z.object({ date: z.string(), bytes: z.number() })),
|
||||
successTrend: z.array(
|
||||
z.object({ date: z.string(), uploadSuccessRate: z.number(), downloadSuccessRate: z.number() }),
|
||||
),
|
||||
failureReasons: z.array(namedPercentValueSchema),
|
||||
})
|
||||
.openapi('AdminDashboardTrafficStats')
|
||||
|
||||
const dashboardSharingStatsSchema = z
|
||||
.object({
|
||||
...statsRangeFields,
|
||||
summary: z.object({
|
||||
activeShares: z.number(),
|
||||
createdShares: deltaSchema,
|
||||
views: deltaSchema,
|
||||
downloads: deltaSchema,
|
||||
saves: deltaSchema,
|
||||
downloadConversionRate: z.number(),
|
||||
}),
|
||||
funnel: z.array(namedPercentValueSchema),
|
||||
trend: z.array(z.object({ date: z.string(), views: z.number(), downloads: z.number(), saves: z.number() })),
|
||||
typeBreakdown: z.array(namedPercentValueSchema),
|
||||
sourceBreakdown: z.array(namedPercentValueSchema),
|
||||
topShares: z.array(topShareWithPercentSchema),
|
||||
})
|
||||
.openapi('AdminDashboardSharingStats')
|
||||
|
||||
const dashboardRankingStatsSchema = z
|
||||
.object({
|
||||
...statsRangeFields,
|
||||
topShares: z.array(topShareWithPercentSchema),
|
||||
topSpaces: z.array(usageBySpaceSchema),
|
||||
storageByType: z.array(storageByTypeSchema),
|
||||
})
|
||||
.openapi('AdminDashboardRankingStats')
|
||||
|
||||
function parseRange(query: z.infer<typeof rangeQuerySchema>): { from?: Date; to?: Date } {
|
||||
return {
|
||||
from: query.from ? parseDashboardDate(query.from, 'start') : undefined,
|
||||
@@ -353,144 +50,27 @@ function parseDashboardDate(value: string, boundary: 'start' | 'end'): Date {
|
||||
return new Date(value)
|
||||
}
|
||||
|
||||
const coreRoute = createRoute({
|
||||
operationId: 'getAdminCoreStats',
|
||||
summary: 'Get admin dashboard core stats',
|
||||
tags: ['Admin Stats'],
|
||||
method: 'get',
|
||||
path: '/core',
|
||||
middleware: [requireAdmin] as const,
|
||||
responses: {
|
||||
200: jsonContent(coreStatsSchema, 'Admin core stats'),
|
||||
401: errorResponse('Unauthorized'),
|
||||
},
|
||||
})
|
||||
|
||||
const detailsRoute = createRoute({
|
||||
operationId: 'getAdminDetailedStats',
|
||||
summary: 'Get admin dashboard detailed stats',
|
||||
tags: ['Admin Stats'],
|
||||
method: 'get',
|
||||
path: '/details',
|
||||
middleware: [requireAdmin, requireFeature('analytics')] as const,
|
||||
request: { query: detailsQuerySchema },
|
||||
responses: {
|
||||
200: jsonContent(detailedStatsSchema, 'Admin detailed stats'),
|
||||
402: errorResponse('Feature not available'),
|
||||
},
|
||||
})
|
||||
|
||||
const overviewRoute = createRoute({
|
||||
operationId: 'getAdminDashboardOverviewStats',
|
||||
summary: 'Get admin dashboard overview stats',
|
||||
tags: ['Admin Stats'],
|
||||
method: 'get',
|
||||
path: '/overview',
|
||||
middleware: [requireAdmin] as const,
|
||||
request: { query: rangeQuerySchema },
|
||||
responses: {
|
||||
200: jsonContent(dashboardOverviewStatsSchema, 'Admin dashboard overview stats'),
|
||||
400: errorResponse('Invalid query'),
|
||||
401: errorResponse('Unauthorized'),
|
||||
},
|
||||
})
|
||||
|
||||
const growthRoute = createRoute({
|
||||
operationId: 'getAdminDashboardGrowthStats',
|
||||
summary: 'Get admin dashboard growth stats',
|
||||
tags: ['Admin Stats'],
|
||||
method: 'get',
|
||||
path: '/growth',
|
||||
middleware: [requireAdmin, requireFeature('analytics')] as const,
|
||||
request: { query: rangeQuerySchema },
|
||||
responses: {
|
||||
200: jsonContent(dashboardGrowthStatsSchema, 'Admin dashboard growth stats'),
|
||||
400: errorResponse('Invalid query'),
|
||||
402: errorResponse('Feature not available'),
|
||||
},
|
||||
})
|
||||
|
||||
const storageRoute = createRoute({
|
||||
operationId: 'getAdminDashboardStorageStats',
|
||||
summary: 'Get admin dashboard storage stats',
|
||||
tags: ['Admin Stats'],
|
||||
method: 'get',
|
||||
path: '/storage',
|
||||
middleware: [requireAdmin, requireFeature('analytics')] as const,
|
||||
request: { query: rangeQuerySchema },
|
||||
responses: {
|
||||
200: jsonContent(dashboardStorageStatsSchema, 'Admin dashboard storage stats'),
|
||||
400: errorResponse('Invalid query'),
|
||||
402: errorResponse('Feature not available'),
|
||||
},
|
||||
})
|
||||
|
||||
const trafficRoute = createRoute({
|
||||
operationId: 'getAdminDashboardTrafficStats',
|
||||
summary: 'Get admin dashboard traffic stats',
|
||||
tags: ['Admin Stats'],
|
||||
method: 'get',
|
||||
path: '/traffic',
|
||||
middleware: [requireAdmin, requireFeature('analytics')] as const,
|
||||
request: { query: rangeQuerySchema },
|
||||
responses: {
|
||||
200: jsonContent(dashboardTrafficStatsSchema, 'Admin dashboard traffic stats'),
|
||||
400: errorResponse('Invalid query'),
|
||||
402: errorResponse('Feature not available'),
|
||||
},
|
||||
})
|
||||
|
||||
const sharingRoute = createRoute({
|
||||
operationId: 'getAdminDashboardSharingStats',
|
||||
summary: 'Get admin dashboard sharing stats',
|
||||
tags: ['Admin Stats'],
|
||||
method: 'get',
|
||||
path: '/sharing',
|
||||
middleware: [requireAdmin, requireFeature('analytics')] as const,
|
||||
request: { query: rangeQuerySchema },
|
||||
responses: {
|
||||
200: jsonContent(dashboardSharingStatsSchema, 'Admin dashboard sharing stats'),
|
||||
400: errorResponse('Invalid query'),
|
||||
402: errorResponse('Feature not available'),
|
||||
},
|
||||
})
|
||||
|
||||
const rankingRoute = createRoute({
|
||||
operationId: 'getAdminDashboardRankingStats',
|
||||
summary: 'Get admin dashboard ranking stats',
|
||||
tags: ['Admin Stats'],
|
||||
method: 'get',
|
||||
path: '/ranking',
|
||||
middleware: [requireAdmin, requireFeature('analytics')] as const,
|
||||
request: { query: rangeQuerySchema },
|
||||
responses: {
|
||||
200: jsonContent(dashboardRankingStatsSchema, 'Admin dashboard ranking stats'),
|
||||
400: errorResponse('Invalid query'),
|
||||
402: errorResponse('Feature not available'),
|
||||
},
|
||||
})
|
||||
|
||||
export const adminStats = new OpenAPIHono<Env>()
|
||||
.openapi(coreRoute, async (c) => c.json(await getAdminCoreStats(c.get('deps')), 200))
|
||||
.openapi(detailsRoute, async (c) => {
|
||||
export const adminStats = new Hono<Env>()
|
||||
.get('/core', requireAdmin, async (c) => c.json(await getAdminCoreStats(c.get('deps')), 200))
|
||||
.get('/details', requireAdmin, requireFeature('analytics'), zValidator('query', detailsQuerySchema), async (c) => {
|
||||
const { periodDays } = c.req.valid('query')
|
||||
return c.json(await getAdminDetailedStats(c.get('deps'), { periodDays }), 200)
|
||||
})
|
||||
.openapi(overviewRoute, async (c) =>
|
||||
.get('/overview', requireAdmin, zValidator('query', rangeQuerySchema), async (c) =>
|
||||
c.json(await getAdminDashboardOverviewStats(c.get('deps'), parseRange(c.req.valid('query'))), 200),
|
||||
)
|
||||
.openapi(growthRoute, async (c) =>
|
||||
.get('/growth', requireAdmin, requireFeature('analytics'), zValidator('query', rangeQuerySchema), async (c) =>
|
||||
c.json(await getAdminDashboardGrowthStats(c.get('deps'), parseRange(c.req.valid('query'))), 200),
|
||||
)
|
||||
.openapi(storageRoute, async (c) =>
|
||||
.get('/storage', requireAdmin, requireFeature('analytics'), zValidator('query', rangeQuerySchema), async (c) =>
|
||||
c.json(await getAdminDashboardStorageStats(c.get('deps'), parseRange(c.req.valid('query'))), 200),
|
||||
)
|
||||
.openapi(trafficRoute, async (c) =>
|
||||
.get('/traffic', requireAdmin, requireFeature('analytics'), zValidator('query', rangeQuerySchema), async (c) =>
|
||||
c.json(await getAdminDashboardTrafficStats(c.get('deps'), parseRange(c.req.valid('query'))), 200),
|
||||
)
|
||||
.openapi(sharingRoute, async (c) =>
|
||||
.get('/sharing', requireAdmin, requireFeature('analytics'), zValidator('query', rangeQuerySchema), async (c) =>
|
||||
c.json(await getAdminDashboardSharingStats(c.get('deps'), parseRange(c.req.valid('query'))), 200),
|
||||
)
|
||||
.openapi(rankingRoute, async (c) =>
|
||||
.get('/ranking', requireAdmin, requireFeature('analytics'), zValidator('query', rangeQuerySchema), async (c) =>
|
||||
c.json(await getAdminDashboardRankingStats(c.get('deps'), parseRange(c.req.valid('query'))), 200),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user