fix(admin): keep stats queries read-only

This commit is contained in:
saltbo
2026-07-09 21:57:20 -04:00
committed by Jasper Van
parent 8eb388ea01
commit b540956fb8
15 changed files with 126 additions and 1315 deletions
@@ -1,10 +1,3 @@
CREATE TABLE `stats_rollup_state` (
`job_name` text PRIMARY KEY NOT NULL,
`cursor_created_at` integer,
`cursor_id` text,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `stats_rollups_daily` (
`id` text PRIMARY KEY NOT NULL,
`bucket_start` integer NOT NULL,
@@ -21,38 +14,22 @@ CREATE TABLE `stats_rollups_daily` (
--> statement-breakpoint
CREATE UNIQUE INDEX `stats_rollups_daily_bucket_metric_dim_uniq` ON `stats_rollups_daily` (`bucket_start`,`org_id`,`metric_key`,`dimension_key`,`dimension_value`);--> statement-breakpoint
CREATE INDEX `stats_rollups_daily_metric_bucket_idx` ON `stats_rollups_daily` (`metric_key`,`bucket_start`);--> statement-breakpoint
CREATE TABLE `stats_rollups_hourly` (
`id` text PRIMARY KEY NOT NULL,
`bucket_start` integer NOT NULL,
`org_id` text DEFAULT '' NOT NULL,
`metric_key` text NOT NULL,
`dimension_key` text DEFAULT '' NOT NULL,
`dimension_value` text DEFAULT '' NOT NULL,
`count` integer DEFAULT 0 NOT NULL,
`bytes` integer DEFAULT 0 NOT NULL,
`unique_count` integer DEFAULT 0 NOT NULL,
`metadata` text,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `stats_rollups_hourly_bucket_metric_dim_uniq` ON `stats_rollups_hourly` (`bucket_start`,`org_id`,`metric_key`,`dimension_key`,`dimension_value`);--> statement-breakpoint
CREATE INDEX `stats_rollups_hourly_metric_bucket_idx` ON `stats_rollups_hourly` (`metric_key`,`bucket_start`);--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_activity_events` (
`id` text PRIMARY KEY NOT NULL,
`org_id` text NOT NULL,
`user_id` text,
`actor_type` text DEFAULT 'user' NOT NULL,
`actor_ref` text,
`action` text NOT NULL,
`target_type` text NOT NULL,
`target_id` text,
`target_name` text NOT NULL,
`metadata` text,
`created_at` integer NOT NULL
`created_at` integer NOT NULL,
`actor_type` text,
`actor_ref` text
);
--> statement-breakpoint
INSERT INTO `__new_activity_events`("id", "org_id", "user_id", "actor_type", "actor_ref", "action", "target_type", "target_id", "target_name", "metadata", "created_at") SELECT "id", "org_id", "user_id", 'user', NULL, "action", "target_type", "target_id", "target_name", "metadata", "created_at" FROM `activity_events`;--> statement-breakpoint
INSERT INTO `__new_activity_events`("id", "org_id", "user_id", "action", "target_type", "target_id", "target_name", "metadata", "created_at", "actor_type", "actor_ref") SELECT "id", "org_id", "user_id", "action", "target_type", "target_id", "target_name", "metadata", "created_at", NULL, NULL FROM `activity_events`;--> statement-breakpoint
DROP TABLE `activity_events`;--> statement-breakpoint
ALTER TABLE `__new_activity_events` RENAME TO `activity_events`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
+15 -167
View File
@@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
"id": "827fe3c3-39c0-4dd3-8b2b-3d1a9f148adc",
"id": "c04d7898-5c72-458a-aa4f-b0a14af47bb9",
"prevId": "788eaeff-a3a7-4da9-aadb-ab59817b494f",
"tables": {
"activity_events": {
@@ -28,21 +28,6 @@
"notNull": false,
"autoincrement": false
},
"actor_type": {
"name": "actor_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"actor_ref": {
"name": "actor_ref",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"action": {
"name": "action",
"type": "text",
@@ -84,6 +69,20 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"actor_type": {
"name": "actor_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"actor_ref": {
"name": "actor_ref",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
@@ -2480,44 +2479,6 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
"stats_rollup_state": {
"name": "stats_rollup_state",
"columns": {
"job_name": {
"name": "job_name",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"cursor_created_at": {
"name": "cursor_created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cursor_id": {
"name": "cursor_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"stats_rollups_daily": {
"name": "stats_rollups_daily",
"columns": {
@@ -2631,119 +2592,6 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
"stats_rollups_hourly": {
"name": "stats_rollups_hourly",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"bucket_start": {
"name": "bucket_start",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"org_id": {
"name": "org_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"metric_key": {
"name": "metric_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dimension_key": {
"name": "dimension_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"dimension_value": {
"name": "dimension_value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"bytes": {
"name": "bytes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"unique_count": {
"name": "unique_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"stats_rollups_hourly_bucket_metric_dim_uniq": {
"name": "stats_rollups_hourly_bucket_metric_dim_uniq",
"columns": [
"bucket_start",
"org_id",
"metric_key",
"dimension_key",
"dimension_value"
],
"isUnique": true
},
"stats_rollups_hourly_metric_bucket_idx": {
"name": "stats_rollups_hourly_metric_bucket_idx",
"columns": [
"metric_key",
"bucket_start"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"storages": {
"name": "storages",
"columns": {
+2 -2
View File
@@ -376,8 +376,8 @@
{
"idx": 54,
"version": "6",
"when": 1783616186203,
"tag": "0054_flippant_lila_cheney",
"when": 1783647878596,
"tag": "0054_lively_nuke",
"breakpoints": true
}
]
+47 -40
View File
@@ -5,12 +5,13 @@ import { activityEvents } from '../../db/schema'
import type { Database } from '../../platform/interface'
import type { ActivityActorType, ActivityRepo } from '../../usecases/ports'
function normalizeActorType(value: string): ActivityActorType {
function normalizeActorType(value: string | null, userId?: string | null): ActivityActorType {
if (value === 'anonymous' || value === 'system' || value === 'downloader') return value
if (!userId) return 'anonymous'
return 'user'
}
function actorDisplayName(actorType: string, actorRef: string | null): string {
function actorDisplayName(actorType: ActivityActorType, actorRef: string | null): string {
if (actorType === 'anonymous') return 'Anonymous'
if (actorType === 'system') return actorRef ? `System:${actorRef}` : 'System'
if (actorType === 'downloader') return actorRef ? `Downloader:${actorRef}` : 'Downloader'
@@ -66,24 +67,27 @@ export function createActivityRepo(db: Database): ActivityRepo {
.limit(pageSize)
.offset(offset)
const items = rows.map((row) => ({
id: row.id,
orgId: row.orgId,
userId: row.userId,
actorType: normalizeActorType(row.actorType),
actorRef: row.actorRef,
action: row.action,
targetType: row.targetType,
targetId: row.targetId,
targetName: row.targetName,
metadata: row.metadata,
createdAt: row.createdAt,
user: {
id: row.userId,
name: row.userName ?? actorDisplayName(row.actorType, row.actorRef),
image: row.userImage ?? null,
},
}))
const items = rows.map((row) => {
const actorType = normalizeActorType(row.actorType, row.userId)
return {
id: row.id,
orgId: row.orgId,
userId: row.userId,
actorType,
actorRef: row.actorRef,
action: row.action,
targetType: row.targetType,
targetId: row.targetId,
targetName: row.targetName,
metadata: row.metadata,
createdAt: row.createdAt,
user: {
id: row.userId,
name: row.userName ?? actorDisplayName(actorType, row.actorRef),
image: row.userImage ?? null,
},
}
})
return { items, total }
},
@@ -132,25 +136,28 @@ export function createActivityRepo(db: Database): ActivityRepo {
.limit(pageSize)
.offset(offset)
const items = rows.map((row) => ({
id: row.id,
orgId: row.orgId,
userId: row.userId,
actorType: normalizeActorType(row.actorType),
actorRef: row.actorRef,
action: row.action,
targetType: row.targetType,
targetId: row.targetId,
targetName: row.targetName,
metadata: row.metadata,
createdAt: row.createdAt,
user: {
id: row.userId,
name: row.userName ?? actorDisplayName(row.actorType, row.actorRef),
image: row.userImage ?? null,
},
orgName: row.orgName ?? null,
}))
const items = rows.map((row) => {
const actorType = normalizeActorType(row.actorType, row.userId)
return {
id: row.id,
orgId: row.orgId,
userId: row.userId,
actorType,
actorRef: row.actorRef,
action: row.action,
targetType: row.targetType,
targetId: row.targetId,
targetName: row.targetName,
metadata: row.metadata,
createdAt: row.createdAt,
user: {
id: row.userId,
name: row.userName ?? actorDisplayName(actorType, row.actorRef),
image: row.userImage ?? null,
},
orgName: row.orgName ?? null,
}
})
return { items, total, page, pageSize }
},
@@ -179,7 +186,7 @@ export function createActivityRepo(db: Database): ActivityRepo {
return {
items: rows.map((row) => ({
...row,
actorType: normalizeActorType(row.actorType),
actorType: normalizeActorType(row.actorType, row.userId),
})),
total: countRows[0]?.count ?? 0,
page,
+13 -537
View File
@@ -7,36 +7,17 @@ import type {
AdminDashboardStorageStats,
AdminDashboardTrafficStats,
AdminStatsDelta,
AdminStatsPoint,
AdminStorageByType,
AdminTopShare,
} from '@shared/types'
import type { SQL } from 'drizzle-orm'
import { and, count, desc, eq, gt, gte, inArray, isNull, lte, sql } from 'drizzle-orm'
import { and, count, desc, eq, gte, inArray, lte, sql } from 'drizzle-orm'
import type { SQLiteTable } from 'drizzle-orm/sqlite-core'
import { account, organization, session, user } from '../../db/auth-schema'
import {
activityEvents,
backgroundJobs,
cloudTrafficReports,
downloaders,
downloadTasks,
matters,
orgQuotas,
shares,
siteInvitations,
statsRollupsDaily,
storages,
webhookEvents,
} from '../../db/schema'
import { activityEvents, cloudTrafficReports, matters, orgQuotas, shares, statsRollupsDaily } from '../../db/schema'
import type { Database } from '../../platform/interface'
import type {
AdminCoreStatsBase,
AdminDetailedStatsBase,
AdminStatsDateRange,
AdminStatsRepo,
} from '../../usecases/ports'
import type { AdminStatsDateRange, AdminStatsRepo } from '../../usecases/ports'
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'
@@ -46,8 +27,6 @@ const GLOBAL_ROLLUP_DIMENSION_VALUE = ''
export function createAdminStatsRepo(db: Database): AdminStatsRepo {
return {
getCoreStatsBase: (now) => getCoreStatsBase(db, now),
getDetailedStatsBase: (now, periodDays) => getDetailedStatsBase(db, now, periodDays),
getDashboardOverviewStats: (now, range) => getDashboardOverviewStats(db, now, range),
getDashboardGrowthStats: (now, range) => getDashboardGrowthStats(db, now, range),
getDashboardStorageStats: (now, range) => getDashboardStorageStats(db, now, range),
@@ -57,109 +36,6 @@ export function createAdminStatsRepo(db: Database): AdminStatsRepo {
}
}
async function getCoreStatsBase(db: Database, now: Date): Promise<AdminCoreStatsBase> {
const last7Days = daysAgo(now, 7)
const last30Days = daysAgo(now, 30)
const [
users,
admins,
newUsers,
activeUsers,
orgs,
storageBackends,
sharing,
pendingInvitations,
failedBackgroundJobs,
offlineDownloaders,
runningDownloadTasks,
] = await Promise.all([
countRows(db, user),
countRowsWhere(db, user, eq(user.role, 'admin')),
countRowsWhere(db, user, gte(user.createdAt, last7Days)),
distinctCount(db, activityEvents.userId, gte(activityEvents.createdAt, last30Days)),
listSpaces(db, last30Days),
getStorageBackends(db),
getSharingStats(db),
countRowsWhere(
db,
siteInvitations,
and(isNull(siteInvitations.acceptedAt), isNull(siteInvitations.revokedAt), gt(siteInvitations.expiresAt, now)),
),
countRowsWhere(db, backgroundJobs, eq(backgroundJobs.status, 'failed')),
countRowsWhere(db, downloaders, eq(downloaders.status, 'offline')),
countRowsWhere(db, downloadTasks, inArray(downloadTasks.status, RUNNING_DOWNLOAD_STATUSES)),
])
return {
users: {
total: users,
admins,
activeLast30Days: activeUsers,
newLast7Days: newUsers,
},
spaces: orgs,
storageBackends,
sharing,
operations: {
pendingInvitations,
failedBackgroundJobs,
offlineDownloaders,
runningDownloadTasks,
},
}
}
async function getDetailedStatsBase(db: Database, now: Date, periodDays: number): Promise<AdminDetailedStatsBase> {
const start = startOfDay(daysAgo(now, periodDays - 1))
const [
trends,
storageByType,
topShares,
sharing,
downloadTotals,
downloadStatus,
failureReasons,
byDownloader,
jobTotals,
jobStatus,
jobFailures,
cloudReports,
] = await Promise.all([
buildTrends(db, start, now, periodDays),
getStorageByType(db),
getTopShares(db),
getDetailedSharing(db, now),
getDownloadTotals(db, start),
getDownloadStatus(db, start),
getDownloadFailureReasons(db, start),
getDownloaderHealth(db, start),
getBackgroundJobTotals(db, start),
getBackgroundJobStatus(db, start),
getBackgroundJobFailures(db, start),
getCloudReportReliability(db),
])
return {
trends,
storageByType,
topShares,
sharing,
remoteDownloads: {
...downloadTotals,
byStatus: downloadStatus,
failureReasons,
byDownloader,
},
backgroundJobs: {
...jobTotals,
byStatus: jobStatus,
failures: jobFailures,
},
cloudTrafficReports: cloudReports,
}
}
async function countRows(db: Database, table: typeof user): Promise<number> {
const rows = await db.select({ value: count() }).from(table)
return toNumber(rows[0]?.value)
@@ -170,134 +46,7 @@ async function countRowsWhere(db: Database, table: SQLiteTable, where: SQL | und
return toNumber(rows[0]?.value)
}
async function distinctCount(
db: Database,
column: typeof activityEvents.userId,
where: ReturnType<typeof gte>,
): Promise<number> {
const rows = await db
.select({ value: sql<number>`COUNT(DISTINCT ${column})` })
.from(activityEvents)
.where(where)
return toNumber(rows[0]?.value)
}
async function listSpaces(db: Database, last30Days: Date): Promise<AdminCoreStatsBase['spaces']> {
const rows = await db
.select({
slug: organization.slug,
metadata: organization.metadata,
createdAt: organization.createdAt,
})
.from(organization)
let personal = 0
let team = 0
let newLast30Days = 0
for (const row of rows) {
if (isPersonalOrgLike(row)) personal += 1
else team += 1
if (row.createdAt && row.createdAt >= last30Days) newLast30Days += 1
}
return { total: rows.length, personal, team, newLast30Days }
}
async function getStorageBackends(db: Database): Promise<AdminCoreStatsBase['storageBackends']> {
const rows = await db
.select({
backendCount: count(),
activeBackendCount: sql<number>`SUM(CASE WHEN ${storages.status} = 'active' THEN 1 ELSE 0 END)`,
capacityBytes: sql<number>`COALESCE(SUM(${storages.capacity}), 0)`,
})
.from(storages)
return {
backendCount: toNumber(rows[0]?.backendCount),
activeBackendCount: toNumber(rows[0]?.activeBackendCount),
capacityBytes: toNumber(rows[0]?.capacityBytes),
}
}
async function getSharingStats(db: Database): Promise<AdminCoreStatsBase['sharing']> {
const rows = await db
.select({
totalShares: count(),
activeShares: sql<number>`SUM(CASE WHEN ${shares.status} = 'active' THEN 1 ELSE 0 END)`,
views: sql<number>`COALESCE(SUM(${shares.views}), 0)`,
downloads: sql<number>`COALESCE(SUM(${shares.downloads}), 0)`,
})
.from(shares)
return {
totalShares: toNumber(rows[0]?.totalShares),
activeShares: toNumber(rows[0]?.activeShares),
views: toNumber(rows[0]?.views),
downloads: toNumber(rows[0]?.downloads),
}
}
async function buildTrends(db: Database, start: Date, now: Date, periodDays: number): Promise<AdminStatsPoint[]> {
const buckets = createTrendBuckets(start, periodDays)
const [usersRows, activityRows, shareRows, taskRows, jobRows] = await Promise.all([
db.select({ createdAt: user.createdAt }).from(user).where(gte(user.createdAt, start)),
db
.select({ userId: activityEvents.userId, createdAt: activityEvents.createdAt })
.from(activityEvents)
.where(gte(activityEvents.createdAt, start)),
db
.select({ createdAt: shares.createdAt, views: shares.views, downloads: shares.downloads })
.from(shares)
.where(gte(shares.createdAt, start)),
db.select({ createdAt: downloadTasks.createdAt }).from(downloadTasks).where(gte(downloadTasks.createdAt, start)),
db
.select({ createdAt: backgroundJobs.createdAt, status: backgroundJobs.status })
.from(backgroundJobs)
.where(gte(backgroundJobs.createdAt, start)),
])
for (const row of usersRows) {
const bucket = buckets.get(dayKey(row.createdAt))
if (bucket) bucket.signups += 1
}
const activeUsersByDay = new Map<string, Set<string>>()
for (const row of activityRows) {
if (!row.userId) continue
const key = dayKey(row.createdAt)
if (!buckets.has(key)) continue
const set = activeUsersByDay.get(key) ?? new Set<string>()
set.add(row.userId)
activeUsersByDay.set(key, set)
}
for (const [key, usersForDay] of activeUsersByDay.entries()) {
const bucket = buckets.get(key)
if (bucket) bucket.activeUsers = usersForDay.size
}
for (const row of shareRows) {
const bucket = buckets.get(dayKey(row.createdAt))
if (!bucket) continue
bucket.shareViews += row.views
bucket.shareDownloads += row.downloads
}
for (const row of taskRows) {
const bucket = buckets.get(dayKey(row.createdAt))
if (bucket) bucket.remoteTasks += 1
}
for (const row of jobRows) {
const bucket = buckets.get(dayKey(row.createdAt))
if (bucket && row.status === 'failed') bucket.failedJobs += 1
}
const todayKey = dayKey(now)
return [...buckets.values()].filter((point) => point.date <= todayKey)
}
async function getStorageByType(
db: Database,
range?: AdminStatsDateRange,
): Promise<AdminDetailedStatsBase['storageByType']> {
async function getStorageByType(db: Database, range?: AdminStatsDateRange): Promise<AdminStorageByType[]> {
const rows = await db
.select({
type: matters.type,
@@ -320,208 +69,6 @@ async function getStorageByType(
return rows.map((row) => ({ type: row.type || 'unknown', files: toNumber(row.files), bytes: toNumber(row.bytes) }))
}
async function getTopShares(db: Database): Promise<AdminDetailedStatsBase['topShares']> {
const rows = await db
.select({
id: shares.id,
token: shares.token,
name: matters.name,
creatorId: shares.creatorId,
creatorName: user.name,
views: shares.views,
downloads: shares.downloads,
status: shares.status,
})
.from(shares)
.leftJoin(matters, eq(matters.id, shares.matterId))
.leftJoin(user, eq(user.id, shares.creatorId))
.orderBy(desc(sql`${shares.views} + ${shares.downloads}`))
.limit(8)
return rows.map((row) => ({
id: row.id,
token: row.token,
name: row.name ?? row.token,
creatorId: row.creatorId,
creatorName: row.creatorName ?? row.creatorId,
views: row.views,
downloads: row.downloads,
status: row.status,
}))
}
async function getDetailedSharing(db: Database, now: Date): Promise<AdminDetailedStatsBase['sharing']> {
const nowSec = unixSeconds(now)
const rows = await db
.select({
expiredShares: sql<number>`SUM(CASE WHEN ${shares.expiresAt} IS NOT NULL AND ${shares.expiresAt} <= ${nowSec} THEN 1 ELSE 0 END)`,
revokedShares: sql<number>`SUM(CASE WHEN ${shares.status} = 'revoked' THEN 1 ELSE 0 END)`,
downloadLimitHitShares: sql<number>`SUM(CASE WHEN ${shares.downloadLimit} IS NOT NULL AND ${shares.downloads} >= ${shares.downloadLimit} THEN 1 ELSE 0 END)`,
views: sql<number>`COALESCE(SUM(${shares.views}), 0)`,
downloads: sql<number>`COALESCE(SUM(${shares.downloads}), 0)`,
})
.from(shares)
const views = toNumber(rows[0]?.views)
const downloads = toNumber(rows[0]?.downloads)
return {
expiredShares: toNumber(rows[0]?.expiredShares),
revokedShares: toNumber(rows[0]?.revokedShares),
downloadLimitHitShares: toNumber(rows[0]?.downloadLimitHitShares),
conversionRate: percent(downloads, views),
}
}
async function getDownloadTotals(
db: Database,
start: Date,
): Promise<
Omit<AdminDetailedStatsBase['remoteDownloads'], 'successRate' | 'byStatus' | 'failureReasons' | 'byDownloader'>
> {
const rows = await db
.select({
total: count(),
completed: sql<number>`SUM(CASE WHEN ${downloadTasks.status} = 'completed' THEN 1 ELSE 0 END)`,
failed: sql<number>`SUM(CASE WHEN ${downloadTasks.status} = 'failed' THEN 1 ELSE 0 END)`,
running: sql<number>`SUM(CASE WHEN ${downloadTasks.status} IN (${RUNNING_DOWNLOAD_STATUSES[0]}, ${RUNNING_DOWNLOAD_STATUSES[1]}, ${RUNNING_DOWNLOAD_STATUSES[2]}, ${RUNNING_DOWNLOAD_STATUSES[3]}, ${RUNNING_DOWNLOAD_STATUSES[4]}) THEN 1 ELSE 0 END)`,
})
.from(downloadTasks)
.where(gte(downloadTasks.createdAt, start))
return {
total: toNumber(rows[0]?.total),
completed: toNumber(rows[0]?.completed),
failed: toNumber(rows[0]?.failed),
running: toNumber(rows[0]?.running),
}
}
async function getDownloadStatus(
db: Database,
start: Date,
): Promise<AdminDetailedStatsBase['remoteDownloads']['byStatus']> {
const rows = await db
.select({ status: downloadTasks.status, count: count() })
.from(downloadTasks)
.where(gte(downloadTasks.createdAt, start))
.groupBy(downloadTasks.status)
.orderBy(desc(count()))
return rows.map((row) => ({ status: row.status, count: toNumber(row.count) }))
}
async function getDownloadFailureReasons(
db: Database,
start: Date,
): Promise<AdminDetailedStatsBase['remoteDownloads']['failureReasons']> {
const rows = await db
.select({
reason: sql<string>`COALESCE(${downloadTasks.errorCode}, ${downloadTasks.errorMessage}, 'unknown')`,
count: count(),
})
.from(downloadTasks)
.where(and(gte(downloadTasks.createdAt, start), eq(downloadTasks.status, 'failed')))
.groupBy(sql`COALESCE(${downloadTasks.errorCode}, ${downloadTasks.errorMessage}, 'unknown')`)
.orderBy(desc(count()))
.limit(8)
return rows.map((row) => ({ reason: row.reason, count: toNumber(row.count) }))
}
async function getDownloaderHealth(
db: Database,
start: Date,
): Promise<AdminDetailedStatsBase['remoteDownloads']['byDownloader']> {
const rows = await db
.select({
downloaderId: downloaders.id,
name: downloaders.name,
status: downloaders.status,
lastHeartbeatAt: downloaders.lastHeartbeatAt,
tasks: sql<number>`COUNT(${downloadTasks.id})`,
failedTasks: sql<number>`SUM(CASE WHEN ${downloadTasks.status} = 'failed' THEN 1 ELSE 0 END)`,
})
.from(downloaders)
.leftJoin(
downloadTasks,
and(eq(downloadTasks.assignedDownloaderId, downloaders.id), gte(downloadTasks.createdAt, start)),
)
.groupBy(downloaders.id)
.orderBy(desc(sql`COUNT(${downloadTasks.id})`))
.limit(8)
return rows.map((row) => ({
downloaderId: row.downloaderId,
name: row.name,
status: row.status,
tasks: toNumber(row.tasks),
failedTasks: toNumber(row.failedTasks),
lastHeartbeatAt: row.lastHeartbeatAt?.toISOString() ?? null,
}))
}
async function getBackgroundJobTotals(
db: Database,
start: Date,
): Promise<Pick<AdminDetailedStatsBase['backgroundJobs'], 'total' | 'failed'>> {
const rows = await db
.select({
total: count(),
failed: sql<number>`SUM(CASE WHEN ${backgroundJobs.status} = 'failed' THEN 1 ELSE 0 END)`,
})
.from(backgroundJobs)
.where(gte(backgroundJobs.createdAt, start))
return { total: toNumber(rows[0]?.total), failed: toNumber(rows[0]?.failed) }
}
async function getBackgroundJobStatus(
db: Database,
start: Date,
): Promise<AdminDetailedStatsBase['backgroundJobs']['byStatus']> {
const rows = await db
.select({ status: backgroundJobs.status, count: count() })
.from(backgroundJobs)
.where(gte(backgroundJobs.createdAt, start))
.groupBy(backgroundJobs.status)
.orderBy(desc(count()))
return rows.map((row) => ({ status: row.status, count: toNumber(row.count) }))
}
async function getBackgroundJobFailures(
db: Database,
start: Date,
): Promise<AdminDetailedStatsBase['backgroundJobs']['failures']> {
const rows = await db
.select({
id: backgroundJobs.id,
type: backgroundJobs.type,
errorMessage: backgroundJobs.errorMessage,
createdAt: backgroundJobs.createdAt,
})
.from(backgroundJobs)
.where(and(gte(backgroundJobs.createdAt, start), eq(backgroundJobs.status, 'failed')))
.orderBy(desc(backgroundJobs.createdAt))
.limit(6)
return rows.map((row) => ({
id: row.id,
type: row.type,
errorMessage: row.errorMessage,
createdAt: row.createdAt.toISOString(),
}))
}
async function getCloudReportReliability(db: Database): Promise<AdminDetailedStatsBase['cloudTrafficReports']> {
const [trafficPending, trafficFailed, webhookPending, webhookFailed] = await Promise.all([
countRowsWhere(db, cloudTrafficReports, eq(cloudTrafficReports.status, 'pending')),
countRowsWhere(db, cloudTrafficReports, eq(cloudTrafficReports.status, 'failed')),
countRowsWhere(db, webhookEvents, eq(webhookEvents.status, 'pending')),
countRowsWhere(db, webhookEvents, eq(webhookEvents.status, 'failed')),
])
return {
pending: trafficPending + webhookPending,
failed: trafficFailed + webhookFailed,
}
}
async function getDashboardOverviewStats(
db: Database,
now: Date,
@@ -552,7 +99,7 @@ async function getDashboardOverviewStats(
const [trendNewUsers, activeByDay, storageUsedByDay, uploadByDay, downloadByDay] = await Promise.all([
getNewUsersByDay(db, range),
getActiveUsersByDay(db, range),
getStorageUsedByDay(db, range, now),
getStorageUsedByDay(db, range),
getActivityBytesByDay(db, range, ['upload_confirm']),
getDownloadBytesByDay(db, range),
])
@@ -656,7 +203,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, now),
getStorageUsedByDay(db, range),
getStorageByType(db, range),
])
const fileItems = files.map((row) => ({ bytes: row.size ?? 0, createdAt: row.createdAt }))
@@ -1088,7 +635,7 @@ async function getActivityMetadataRows(db: Database, range: AdminStatsDateRange,
)
}
async function getStorageUsedByDay(db: Database, range: AdminStatsDateRange, now: Date): Promise<Map<string, number>> {
async function getStorageUsedByDay(db: Database, range: AdminStatsDateRange): Promise<Map<string, number>> {
const buckets = createDateBuckets(range)
if (buckets.length === 0) return new Map()
@@ -1112,52 +659,11 @@ async function getStorageUsedByDay(db: Database, range: AdminStatsDateRange, now
)
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 missingBuckets = buckets.filter((date) => !byDay.has(date))
if (missingBuckets.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,
],
})
}
}
const computed = await computeStorageUsedByDayFromCurrentFiles(db, missingBuckets)
for (const date of missingBuckets) byDay.set(date, computed.get(date) ?? 0)
return byDay
}
@@ -1294,10 +800,7 @@ async function getTopSharesWithPercent(
}))
}
async function getTopSharesByActivity(
db: Database,
range: AdminStatsDateRange,
): Promise<AdminDetailedStatsBase['topShares']> {
async function getTopSharesByActivity(db: Database, range: AdminStatsDateRange): Promise<AdminTopShare[]> {
const activityRows = await db
.select({ targetId: activityEvents.targetId, action: activityEvents.action })
.from(activityEvents)
@@ -1530,25 +1033,6 @@ function distinctUsersInWindow(records: Array<{ userId: string; at: Date }>, fro
return ids.size
}
function createTrendBuckets(start: Date, periodDays: number): Map<string, AdminStatsPoint> {
const buckets = new Map<string, AdminStatsPoint>()
for (let i = 0; i < periodDays; i += 1) {
const date = new Date(start)
date.setUTCDate(start.getUTCDate() + i)
const key = dayKey(date)
buckets.set(key, {
date: key,
signups: 0,
activeUsers: 0,
shareViews: 0,
shareDownloads: 0,
remoteTasks: 0,
failedJobs: 0,
})
}
return buckets
}
function daysAgo(now: Date, days: number): Date {
const date = new Date(now)
date.setUTCDate(date.getUTCDate() - days)
@@ -1571,14 +1055,6 @@ 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)
}
function percent(part: number, total: number): number {
if (total <= 0) return 0
return Math.round((part / total) * 1000) / 10
+2 -36
View File
@@ -443,14 +443,14 @@ export const activityEvents = sqliteTable(
id: text('id').primaryKey(),
orgId: text('org_id').notNull(),
userId: text('user_id'),
actorType: text('actor_type').notNull().default('user'),
actorRef: text('actor_ref'),
action: text('action').notNull(), // 'upload', 'create', 'delete', 'rename', 'move', 'restore'
targetType: text('target_type').notNull(), // 'file', 'folder'
targetId: text('target_id'),
targetName: text('target_name').notNull(),
metadata: text('metadata'), // JSON
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
actorType: text('actor_type'),
actorRef: text('actor_ref'),
},
(t) => [
index('activity_events_org_created_idx').on(t.orgId, t.createdAt),
@@ -460,33 +460,6 @@ export const activityEvents = sqliteTable(
],
)
export const statsRollupsHourly = sqliteTable(
'stats_rollups_hourly',
{
id: text('id').primaryKey(),
bucketStart: integer('bucket_start', { mode: 'timestamp_ms' }).notNull(),
orgId: text('org_id').notNull().default(''),
metricKey: text('metric_key').notNull(),
dimensionKey: text('dimension_key').notNull().default(''),
dimensionValue: text('dimension_value').notNull().default(''),
count: integer('count').notNull().default(0),
bytes: integer('bytes').notNull().default(0),
uniqueCount: integer('unique_count').notNull().default(0),
metadata: text('metadata'),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
},
(t) => [
uniqueIndex('stats_rollups_hourly_bucket_metric_dim_uniq').on(
t.bucketStart,
t.orgId,
t.metricKey,
t.dimensionKey,
t.dimensionValue,
),
index('stats_rollups_hourly_metric_bucket_idx').on(t.metricKey, t.bucketStart),
],
)
export const statsRollupsDaily = sqliteTable(
'stats_rollups_daily',
{
@@ -514,13 +487,6 @@ export const statsRollupsDaily = sqliteTable(
],
)
export const statsRollupState = sqliteTable('stats_rollup_state', {
jobName: text('job_name').primaryKey(),
cursorCreatedAt: integer('cursor_created_at', { mode: 'timestamp_ms' }),
cursorId: text('cursor_id'),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
})
export const shares = sqliteTable(
'shares',
{
+23 -50
View File
@@ -4,71 +4,29 @@ import { currentTrafficPeriod } from '../domain/quota'
import { adminHeaders, createTestApp, seedProLicense } from '../test/setup.js'
describe('admin stats routes', () => {
it('returns core dashboard stats for admins without a Pro license', async () => {
const { app, db } = await createTestApp()
it('does not expose legacy core or details stats endpoints', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
const { orgId, userId } = await seedStatsFixture(db)
const res = await app.request('/api/admin/stats/core', { headers })
const body = (await res.json()) as {
users: { total: number; admins: number }
storage: { usedBytes: number; backendCount: number }
sharing: { views: number; downloads: number }
operations: { pendingInvitations: number }
}
const coreRes = await app.request('/api/admin/stats/core', { headers })
const detailsRes = await app.request('/api/admin/stats/details', { headers })
expect(res.status).toBe(200)
expect(orgId).toBeTruthy()
expect(userId).toBeTruthy()
expect(body.users.total).toBeGreaterThanOrEqual(1)
expect(body.users.admins).toBe(1)
expect(body.storage.usedBytes).toBe(512)
expect(body.storage.backendCount).toBe(1)
expect(body.sharing.views).toBe(12)
expect(body.sharing.downloads).toBe(4)
expect(body.operations.pendingInvitations).toBe(1)
expect(coreRes.status).toBe(404)
expect(detailsRes.status).toBe(404)
})
it('gates detailed dashboard stats behind the analytics feature', async () => {
it('gates advanced dashboard stats behind the analytics feature', async () => {
const { app, db } = await createTestApp()
const headers = await adminHeaders(app)
await seedStatsFixture(db)
const res = await app.request('/api/admin/stats/details', { headers })
const res = await app.request('/api/admin/stats/storage', { headers })
const body = (await res.json()) as { error: { details: Array<{ metadata: Record<string, string> }> } }
expect(res.status).toBe(402)
expect(body.error.details[0].metadata.feature).toBe('analytics')
})
it('returns detailed dashboard stats for Pro admins', async () => {
const { app, db } = await createTestApp()
const headers = await adminHeaders(app)
await seedProLicense(db)
await seedStatsFixture(db)
const res = await app.request('/api/admin/stats/details?periodDays=7', { headers })
const body = (await res.json()) as {
periodDays: number
trends: Array<{ remoteTasks: number; failedJobs: number }>
topShares: Array<{ token: string; views: number }>
remoteDownloads: { total: number; completed: number; failed: number; successRate: number }
reliability: {
backgroundJobs: { failed: number }
license: { active: boolean; edition: string; lastRefreshAt: string }
}
}
expect(res.status).toBe(200)
expect(body.periodDays).toBe(7)
expect(body.topShares[0]).toMatchObject({ token: 'share-token-1', views: 12 })
expect(body.remoteDownloads).toMatchObject({ total: 2, completed: 1, failed: 1, successRate: 50 })
expect(body.reliability.backgroundJobs.failed).toBe(1)
expect(body.reliability.license).toMatchObject({ active: true, edition: 'pro' })
expect(body.reliability.license.lastRefreshAt).toMatch(/^20\d{2}-/)
expect(body.trends.some((point) => point.remoteTasks > 0 || point.failedJobs > 0)).toBe(true)
})
it('normalizes date-only dashboard ranges to exact daily buckets', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
@@ -135,6 +93,21 @@ describe('admin stats routes', () => {
expect(body.storageTrend).toEqual([{ date: '2026-01-01', usedBytes: 4096, newBytes: 0, newFiles: 0 }])
})
it('does not write rollups while serving storage stats fallback data', async () => {
const { app, db } = await createTestApp()
const headers = await adminHeaders(app)
await seedProLicense(db)
await seedStatsFixture(db)
const before = await db.all<{ count: number }>(sql`SELECT COUNT(*) AS count FROM stats_rollups_daily`)
const res = await app.request('/api/admin/stats/storage?from=2000-01-01&to=2000-01-02', { headers })
const after = await db.all<{ count: number }>(sql`SELECT COUNT(*) AS count FROM stats_rollups_daily`)
expect(res.status).toBe(200)
expect(before[0].count).toBe(0)
expect(after[0].count).toBe(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)
-11
View File
@@ -5,20 +5,14 @@ import { requireAdmin } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import { requireFeature } from '../middleware/require-feature'
import {
getAdminCoreStats,
getAdminDashboardGrowthStats,
getAdminDashboardOverviewStats,
getAdminDashboardRankingStats,
getAdminDashboardSharingStats,
getAdminDashboardStorageStats,
getAdminDashboardTrafficStats,
getAdminDetailedStats,
} from '../usecases/admin-stats'
const detailsQuerySchema = z.object({
periodDays: z.coerce.number().int().min(7).max(90).default(30),
})
const dashboardDateSchema = z.string().refine((value) => isValidDashboardDate(value), {
message: 'Expected yyyy-MM-dd or ISO datetime',
})
@@ -51,11 +45,6 @@ function parseDashboardDate(value: string, boundary: 'start' | 'end'): Date {
}
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)
})
.get('/overview', requireAdmin, zValidator('query', rangeQuerySchema), async (c) =>
c.json(await getAdminDashboardOverviewStats(c.get('deps'), parseRange(c.req.valid('query'))), 200),
)
+19 -40
View File
@@ -285,31 +285,25 @@ const APP_SCHEMA_SQL = `
);
CREATE UNIQUE INDEX IF NOT EXISTS team_invite_links_token_unique ON team_invite_links(token);
CREATE TABLE IF NOT EXISTS activity_events (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
user_id TEXT,
actor_type TEXT NOT NULL DEFAULT 'user',
actor_ref TEXT,
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT,
target_name TEXT NOT NULL,
metadata TEXT,
created_at INTEGER NOT NULL
);
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
user_id TEXT,
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT,
target_name TEXT NOT NULL,
metadata TEXT,
created_at INTEGER NOT NULL,
actor_type TEXT,
actor_ref TEXT
);
CREATE INDEX IF NOT EXISTS activity_events_org_created_idx ON activity_events(org_id, created_at);
CREATE INDEX IF NOT EXISTS activity_events_user_created_idx ON activity_events(user_id, created_at);
CREATE INDEX IF NOT EXISTS activity_events_action_created_idx ON activity_events(action, created_at);
CREATE INDEX IF NOT EXISTS activity_events_target_created_idx ON activity_events(target_type, target_id, created_at);
CREATE TABLE IF NOT EXISTS stats_rollup_state (
job_name TEXT PRIMARY KEY NOT NULL,
cursor_created_at INTEGER,
cursor_id TEXT,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS stats_rollups_daily (
id TEXT PRIMARY KEY NOT NULL,
bucket_start INTEGER NOT NULL,
CREATE TABLE IF NOT EXISTS stats_rollups_daily (
id TEXT PRIMARY KEY NOT NULL,
bucket_start INTEGER NOT NULL,
org_id TEXT NOT NULL DEFAULT '',
metric_key TEXT NOT NULL,
dimension_key TEXT NOT NULL DEFAULT '',
@@ -319,25 +313,10 @@ const APP_SCHEMA_SQL = `
unique_count INTEGER NOT NULL DEFAULT 0,
metadata TEXT,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS stats_rollups_daily_bucket_metric_dim_uniq ON stats_rollups_daily(bucket_start, org_id, metric_key, dimension_key, dimension_value);
CREATE INDEX IF NOT EXISTS stats_rollups_daily_metric_bucket_idx ON stats_rollups_daily(metric_key, bucket_start);
CREATE TABLE IF NOT EXISTS stats_rollups_hourly (
id TEXT PRIMARY KEY NOT NULL,
bucket_start INTEGER NOT NULL,
org_id TEXT NOT NULL DEFAULT '',
metric_key TEXT NOT NULL,
dimension_key TEXT NOT NULL DEFAULT '',
dimension_value TEXT NOT NULL DEFAULT '',
count INTEGER NOT NULL DEFAULT 0,
bytes INTEGER NOT NULL DEFAULT 0,
unique_count INTEGER NOT NULL DEFAULT 0,
metadata TEXT,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS stats_rollups_hourly_bucket_metric_dim_uniq ON stats_rollups_hourly(bucket_start, org_id, metric_key, dimension_key, dimension_value);
CREATE INDEX IF NOT EXISTS stats_rollups_hourly_metric_bucket_idx ON stats_rollups_hourly(metric_key, bucket_start);
CREATE TABLE IF NOT EXISTS shares (
);
CREATE UNIQUE INDEX IF NOT EXISTS stats_rollups_daily_bucket_metric_dim_uniq ON stats_rollups_daily(bucket_start, org_id, metric_key, dimension_key, dimension_value);
CREATE INDEX IF NOT EXISTS stats_rollups_daily_metric_bucket_idx ON stats_rollups_daily(metric_key, bucket_start);
CREATE TABLE IF NOT EXISTS shares (
id TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL,
+1 -104
View File
@@ -1,112 +1,15 @@
import type {
AdminCoreStats,
AdminDashboardGrowthStats,
AdminDashboardOverviewStats,
AdminDashboardRankingStats,
AdminDashboardSharingStats,
AdminDashboardStorageStats,
AdminDashboardTrafficStats,
AdminDetailedStats,
AdminUsageBySpace,
} from '@shared/types'
import { currentTrafficPeriod } from '../domain/quota'
import { percent } from './admin-stats-utils'
import {
type AdminStatsDateRange,
type AdminStatsRepo,
badRequest,
type LicenseBindingRepo,
type QuotaRepo,
} from './ports'
import { listQuotaOverview } from './quota'
import { loadBindingState } from './site/licensing'
import { type AdminStatsDateRange, type AdminStatsRepo, badRequest } from './ports'
export type AdminStatsDeps = {
adminStats: AdminStatsRepo
quota: Pick<QuotaRepo, 'listOrgQuotaOverview' | 'getEffectiveQuotasByOrg'>
licenseBinding: LicenseBindingRepo
}
export async function getAdminCoreStats(deps: AdminStatsDeps, now = new Date()): Promise<AdminCoreStats> {
const [base, quotas] = await Promise.all([deps.adminStats.getCoreStatsBase(now), listQuotaOverview(deps, now)])
const quotaItems = quotas.items
const usedBytes = quotaItems.reduce((sum, item) => sum + item.used, 0)
const quotaBytes = quotaItems.reduce((sum, item) => sum + item.quota, 0)
const trafficUsedBytes = quotaItems.reduce((sum, item) => sum + item.trafficUsed, 0)
const trafficQuotaBytes = quotaItems.reduce((sum, item) => sum + item.trafficQuota, 0)
return {
generatedAt: now.toISOString(),
users: base.users,
spaces: base.spaces,
storage: {
usedBytes,
quotaBytes,
quotaUtilization: percent(usedBytes, quotaBytes),
capacityBytes: base.storageBackends.capacityBytes,
backendCount: base.storageBackends.backendCount,
activeBackendCount: base.storageBackends.activeBackendCount,
},
traffic: {
usedBytes: trafficUsedBytes,
quotaBytes: trafficQuotaBytes,
utilization: percent(trafficUsedBytes, trafficQuotaBytes),
period: quotaItems[0]?.trafficPeriod ?? currentTrafficPeriod(now),
},
sharing: base.sharing,
operations: base.operations,
}
}
export async function getAdminDetailedStats(
deps: AdminStatsDeps,
params: { periodDays: number },
now = new Date(),
): Promise<AdminDetailedStats> {
const periodDays = normalizePeriodDays(params.periodDays)
const [base, quotas, license] = await Promise.all([
deps.adminStats.getDetailedStatsBase(now, periodDays),
listQuotaOverview(deps, now),
loadBindingState(deps),
])
const usageBySpace = quotas.items
.map<AdminUsageBySpace>((item) => ({
orgId: item.orgId,
orgName: item.orgName,
orgType: item.orgType,
usedBytes: item.used,
quotaBytes: item.quota,
utilization: percent(item.used, item.quota),
}))
.sort((a, b) => b.utilization - a.utilization || b.usedBytes - a.usedBytes)
.slice(0, 8)
return {
generatedAt: now.toISOString(),
periodDays,
trends: base.trends,
usageBySpace,
storageByType: base.storageByType,
topShares: base.topShares,
sharing: base.sharing,
remoteDownloads: {
...base.remoteDownloads,
successRate: percent(base.remoteDownloads.completed, base.remoteDownloads.total),
},
reliability: {
backgroundJobs: {
...base.backgroundJobs,
failureRate: percent(base.backgroundJobs.failed, base.backgroundJobs.total),
},
cloudTrafficReports: base.cloudTrafficReports,
license: {
active: Boolean(license.active),
edition: license.edition ?? null,
lastRefreshAt: license.last_refresh_at ? new Date(license.last_refresh_at * 1000).toISOString() : null,
lastRefreshError: license.last_refresh_error ?? null,
},
},
}
}
export interface AdminStatsRangeInput {
@@ -175,12 +78,6 @@ export function getAdminDashboardRankingStats(
return deps.adminStats.getDashboardRankingStats(now, normalizeStatsRange(input, now))
}
function normalizePeriodDays(value: number): number {
if (value <= 7) return 7
if (value <= 30) return 30
return 90
}
function daysAgo(now: Date, days: number): Date {
const date = new Date(now)
date.setUTCDate(date.getUTCDate() - days)
-37
View File
@@ -1,50 +1,13 @@
import type {
AdminBackgroundJobFailure,
AdminCoreStats,
AdminCountByStatus,
AdminDashboardGrowthStats,
AdminDashboardOverviewStats,
AdminDashboardRankingStats,
AdminDashboardSharingStats,
AdminDashboardStorageStats,
AdminDashboardTrafficStats,
AdminDetailedStats,
AdminDownloaderHealth,
AdminDownloadFailureReason,
AdminStatsPoint,
AdminStorageByType,
AdminTopShare,
} from '@shared/types'
export interface AdminCoreStatsBase {
users: AdminCoreStats['users']
spaces: AdminCoreStats['spaces']
storageBackends: Pick<AdminCoreStats['storage'], 'capacityBytes' | 'backendCount' | 'activeBackendCount'>
sharing: AdminCoreStats['sharing']
operations: AdminCoreStats['operations']
}
export interface AdminDetailedStatsBase {
trends: AdminStatsPoint[]
storageByType: AdminStorageByType[]
topShares: AdminTopShare[]
sharing: AdminDetailedStats['sharing']
remoteDownloads: Omit<AdminDetailedStats['remoteDownloads'], 'successRate'> & {
failureReasons: AdminDownloadFailureReason[]
byDownloader: AdminDownloaderHealth[]
}
backgroundJobs: {
total: number
failed: number
byStatus: AdminCountByStatus[]
failures: AdminBackgroundJobFailure[]
}
cloudTrafficReports: AdminDetailedStats['reliability']['cloudTrafficReports']
}
export interface AdminStatsRepo {
getCoreStatsBase(now: Date): Promise<AdminCoreStatsBase>
getDetailedStatsBase(now: Date, periodDays: number): Promise<AdminDetailedStatsBase>
getDashboardOverviewStats(now: Date, range: AdminStatsDateRange): Promise<AdminDashboardOverviewStats>
getDashboardGrowthStats(now: Date, range: AdminStatsDateRange): Promise<AdminDashboardGrowthStats>
getDashboardStorageStats(now: Date, range: AdminStatsDateRange): Promise<AdminDashboardStorageStats>
-122
View File
@@ -1,55 +1,3 @@
export interface AdminCoreStats {
generatedAt: string
users: {
total: number
admins: number
activeLast30Days: number
newLast7Days: number
}
spaces: {
total: number
personal: number
team: number
newLast30Days: number
}
storage: {
usedBytes: number
quotaBytes: number
quotaUtilization: number
capacityBytes: number
backendCount: number
activeBackendCount: number
}
traffic: {
usedBytes: number
quotaBytes: number
utilization: number
period: string
}
sharing: {
totalShares: number
activeShares: number
views: number
downloads: number
}
operations: {
pendingInvitations: number
failedBackgroundJobs: number
offlineDownloaders: number
runningDownloadTasks: number
}
}
export interface AdminStatsPoint {
date: string
signups: number
activeUsers: number
shareViews: number
shareDownloads: number
remoteTasks: number
failedJobs: number
}
export interface AdminUsageBySpace {
orgId: string
orgName: string
@@ -76,76 +24,6 @@ export interface AdminTopShare {
status: string
}
export interface AdminCountByStatus {
status: string
count: number
}
export interface AdminDownloadFailureReason {
reason: string
count: number
}
export interface AdminDownloaderHealth {
downloaderId: string
name: string
status: string
tasks: number
failedTasks: number
lastHeartbeatAt: string | null
}
export interface AdminBackgroundJobFailure {
id: string
type: string
errorMessage: string | null
createdAt: string
}
export interface AdminDetailedStats {
generatedAt: string
periodDays: number
trends: AdminStatsPoint[]
usageBySpace: AdminUsageBySpace[]
storageByType: AdminStorageByType[]
topShares: AdminTopShare[]
sharing: {
expiredShares: number
revokedShares: number
downloadLimitHitShares: number
conversionRate: number
}
remoteDownloads: {
total: number
completed: number
failed: number
running: number
successRate: number
byStatus: AdminCountByStatus[]
failureReasons: AdminDownloadFailureReason[]
byDownloader: AdminDownloaderHealth[]
}
reliability: {
backgroundJobs: {
total: number
failed: number
failureRate: number
byStatus: AdminCountByStatus[]
failures: AdminBackgroundJobFailure[]
}
cloudTrafficReports: {
pending: number
failed: number
}
license: {
active: boolean
edition: string | null
lastRefreshAt: string | null
lastRefreshError: string | null
}
}
}
export interface AdminStatsRange {
generatedAt: string
from: string
-7
View File
@@ -210,20 +210,13 @@ export interface PaginatedResponse<T> {
}
export type {
AdminBackgroundJobFailure,
AdminCoreStats,
AdminCountByStatus,
AdminDashboardGrowthStats,
AdminDashboardOverviewStats,
AdminDashboardRankingStats,
AdminDashboardSharingStats,
AdminDashboardStorageStats,
AdminDashboardTrafficStats,
AdminDetailedStats,
AdminDownloaderHealth,
AdminDownloadFailureReason,
AdminStatsDelta,
AdminStatsPoint,
AdminStatsRange,
AdminStorageByType,
AdminTopShare,
-125
View File
@@ -39,14 +39,12 @@ import {
disconnectCloud,
enableIhostFeature,
generateInviteCodes,
getAdminCoreStats,
getAdminDashboardGrowthStats,
getAdminDashboardOverviewStats,
getAdminDashboardRankingStats,
getAdminDashboardSharingStats,
getAdminDashboardStorageStats,
getAdminDashboardTrafficStats,
getAdminDetailedStats,
getAnnouncement,
getBackgroundJob,
getBranding,
@@ -3818,129 +3816,6 @@ describe('api', () => {
})
describe('admin stats API', () => {
const corePayload = {
generatedAt: '2026-07-09T00:00:00.000Z',
users: { total: 3, admins: 1, activeLast30Days: 2, newLast7Days: 1 },
spaces: { total: 4, personal: 3, team: 1, newLast30Days: 1 },
storage: {
usedBytes: 100,
quotaBytes: 1000,
quotaUtilization: 10,
capacityBytes: 2000,
backendCount: 2,
activeBackendCount: 1,
},
traffic: { usedBytes: 50, quotaBytes: 500, utilization: 10, period: '2026-07' },
sharing: { totalShares: 4, activeShares: 3, views: 20, downloads: 5 },
operations: { pendingInvitations: 1, failedBackgroundJobs: 2, offlineDownloaders: 1, runningDownloadTasks: 3 },
}
const detailedPayload = {
generatedAt: '2026-07-09T00:00:00.000Z',
periodDays: 30,
trends: [
{
date: '2026-07-09',
signups: 1,
activeUsers: 2,
shareViews: 10,
shareDownloads: 4,
remoteTasks: 3,
failedJobs: 1,
},
],
usageBySpace: [
{ orgId: 'org-1', orgName: 'Team', orgType: 'team', usedBytes: 100, quotaBytes: 200, utilization: 50 },
],
storageByType: [{ type: 'image/png', bytes: 100, files: 2 }],
topShares: [
{
id: 'share-1',
token: 'token-1',
name: 'file.png',
creatorId: 'user-1',
creatorName: 'Alice',
views: 10,
downloads: 4,
status: 'active',
},
],
sharing: { expiredShares: 1, revokedShares: 1, downloadLimitHitShares: 1, conversionRate: 40 },
remoteDownloads: {
total: 5,
completed: 3,
failed: 1,
running: 1,
successRate: 60,
byStatus: [{ status: 'completed', count: 3 }],
failureReasons: [{ reason: 'network', count: 1 }],
byDownloader: [
{ downloaderId: 'dl-1', name: 'Node', status: 'online', tasks: 5, failedTasks: 1, lastHeartbeatAt: null },
],
},
reliability: {
backgroundJobs: {
total: 4,
failed: 1,
failureRate: 25,
byStatus: [{ status: 'failed', count: 1 }],
failures: [{ id: 'job-1', type: 'extract', errorMessage: 'bad zip', createdAt: '2026-07-09T00:00:00.000Z' }],
},
cloudTrafficReports: { pending: 1, failed: 1 },
license: { active: true, edition: 'pro', lastRefreshAt: null, lastRefreshError: null },
},
}
it('getAdminCoreStats fetches core dashboard stats', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(corePayload))
const result = await getAdminCoreStats()
expect(result).toEqual(corePayload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/admin/stats/core')
expect(init.method).toBe('GET')
})
it('getAdminCoreStats throws ApiError on failure', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
await expect(getAdminCoreStats()).rejects.toThrow('Forbidden')
})
it('getAdminDetailedStats fetches detailed dashboard stats with periodDays', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(detailedPayload))
const result = await getAdminDetailedStats(90)
expect(result).toEqual(detailedPayload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/admin/stats/details')
expect(url).toContain('periodDays=90')
expect(init.method).toBe('GET')
})
it('getAdminDetailedStats defaults to 30 days', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(detailedPayload))
await getAdminDetailedStats()
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
expect(url).toContain('periodDays=30')
})
it('getAdminDetailedStats throws ApiError on feature gate failure', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
makeResponse(
{ error: { code: 402, message: 'Feature not available', status: 'FAILED_PRECONDITION' } },
false,
402,
),
)
await expect(getAdminDetailedStats()).rejects.toMatchObject({ status: 402 })
})
const dashboardPayload = {
generatedAt: '2026-07-09T00:00:00.000Z',
from: '2026-07-01T00:00:00.000Z',
-10
View File
@@ -25,14 +25,12 @@ import type {
import type {
ActivityEvent,
AdminAuditEvent,
AdminCoreStats,
AdminDashboardGrowthStats,
AdminDashboardOverviewStats,
AdminDashboardRankingStats,
AdminDashboardSharingStats,
AdminDashboardStorageStats,
AdminDashboardTrafficStats,
AdminDetailedStats,
Announcement,
AuthProvider,
AuthProviderList,
@@ -436,14 +434,6 @@ export function retryBackgroundJob(id: string) {
// Admin dashboard stats
export function getAdminCoreStats() {
return unwrap<AdminCoreStats>(adminStatsApi.core.$get())
}
export function getAdminDetailedStats(periodDays = 30) {
return unwrap<AdminDetailedStats>(adminStatsApi.details.$get({ query: { periodDays: String(periodDays) } }))
}
export interface AdminStatsRangeFilter {
from?: string
to?: string