feat(admin): implement operations dashboard stats

This commit is contained in:
saltbo
2026-07-09 23:34:03 -04:00
committed by Jasper Van
parent 983a5540bd
commit b7f7fa7ecd
40 changed files with 8468 additions and 1000 deletions
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
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,
`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_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
);
--> 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
DROP TABLE `activity_events`;--> statement-breakpoint
ALTER TABLE `__new_activity_events` RENAME TO `activity_events`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE INDEX `activity_events_org_created_idx` ON `activity_events` (`org_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `activity_events_user_created_idx` ON `activity_events` (`user_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `activity_events_action_created_idx` ON `activity_events` (`action`,`created_at`);--> statement-breakpoint
CREATE INDEX `activity_events_target_created_idx` ON `activity_events` (`target_type`,`target_id`,`created_at`);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -372,6 +372,13 @@
"when": 1782781065387,
"tag": "0053_striped_pride",
"breakpoints": true
},
{
"idx": 54,
"version": "6",
"when": 1783616186203,
"tag": "0054_flippant_lila_cheney",
"breakpoints": true
}
]
}
+3
View File
@@ -71,7 +71,9 @@
"better-sqlite3": "^12.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"drizzle-orm": "^0.45.2",
"echarts": "^6.1.0",
"fflate": "^0.8.2",
"hono": "^4.12.25",
"i18next": "^26.0.3",
@@ -85,6 +87,7 @@
"posthog-node": "^5.36.4",
"radix-ui": "^1.4.3",
"react": "^19.2.5",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.5",
"react-dropzone": "^15.0.0",
"react-hook-form": "^7.54.0",
+57
View File
@@ -92,9 +92,15 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
date-fns:
specifier: ^4.4.0
version: 4.4.0
drizzle-orm:
specifier: ^0.45.2
version: 0.45.2(@cloudflare/workers-types@4.20260606.1)(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.28.17)
echarts:
specifier: ^6.1.0
version: 6.1.0
fflate:
specifier: ^0.8.2
version: 0.8.2
@@ -134,6 +140,9 @@ importers:
react:
specifier: ^19.2.5
version: 19.2.5
react-day-picker:
specifier: ^10.0.1
version: 10.0.1(@types/react@19.2.14)(react@19.2.5)
react-dom:
specifier: ^19.2.5
version: 19.2.5(react@19.2.5)
@@ -815,6 +824,9 @@ packages:
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@date-fns/tz@1.5.0':
resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==}
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
@@ -3800,6 +3812,9 @@ packages:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
date-fns@4.4.0:
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -3959,6 +3974,9 @@ packages:
sqlite3:
optional: true
echarts@6.1.0:
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
electron-to-chromium@1.5.364:
resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==}
@@ -4951,6 +4969,16 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
react-day-picker@10.0.1:
resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==}
engines: {node: '>=18'}
peerDependencies:
'@types/react': '>=16.8.0'
react: '>=16.8.0'
peerDependenciesMeta:
'@types/react':
optional: true
react-dom@19.2.5:
resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==}
peerDependencies:
@@ -5470,6 +5498,9 @@ packages:
resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==}
engines: {node: '>=6'}
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -5856,6 +5887,9 @@ packages:
hono: ^4.7.11
zod: ^4.3.6
zrender@6.1.0:
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@@ -6561,6 +6595,8 @@ snapshots:
'@csstools/css-tokenizer@4.0.0': {}
'@date-fns/tz@1.5.0': {}
'@dnd-kit/accessibility@3.1.1(react@19.2.5)':
dependencies:
react: 19.2.5
@@ -9141,6 +9177,8 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
date-fns@4.4.0: {}
debug@4.4.3:
dependencies:
ms: 2.1.3
@@ -9216,6 +9254,11 @@ snapshots:
better-sqlite3: 12.10.0
kysely: 0.28.17
echarts@6.1.0:
dependencies:
tslib: 2.3.0
zrender: 6.1.0
electron-to-chromium@1.5.364: {}
emoji-regex@10.6.0: {}
@@ -10534,6 +10577,14 @@ snapshots:
minimist: 1.2.8
strip-json-comments: 2.0.1
react-day-picker@10.0.1(@types/react@19.2.14)(react@19.2.5):
dependencies:
'@date-fns/tz': 1.5.0
date-fns: 4.4.0
react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
react-dom@19.2.5(react@19.2.5):
dependencies:
react: 19.2.5
@@ -11177,6 +11228,8 @@ snapshots:
minimist: 1.2.8
strip-bom: 3.0.0
tslib@2.3.0: {}
tslib@2.8.1: {}
tsup@8.5.1(@swc/core@1.15.40)(jiti@2.7.0)(postcss@8.5.15)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0):
@@ -11554,4 +11607,8 @@ snapshots:
hono: 4.12.25
zod: 4.4.3
zrender@6.1.0:
dependencies:
tslib: 2.3.0
zwitch@2.0.4: {}
+38 -5
View File
@@ -3,7 +3,19 @@ import { nanoid } from 'nanoid'
import { organization, user } from '../../db/auth-schema'
import { activityEvents } from '../../db/schema'
import type { Database } from '../../platform/interface'
import type { ActivityRepo } from '../../usecases/ports'
import type { ActivityActorType, ActivityRepo } from '../../usecases/ports'
function normalizeActorType(value: string): ActivityActorType {
if (value === 'anonymous' || value === 'system' || value === 'downloader') return value
return 'user'
}
function actorDisplayName(actorType: string, 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'
return ''
}
export function createActivityRepo(db: Database): ActivityRepo {
return {
@@ -11,7 +23,9 @@ export function createActivityRepo(db: Database): ActivityRepo {
await db.insert(activityEvents).values({
id: nanoid(),
orgId: event.orgId,
userId: event.userId,
userId: event.userId ?? null,
actorType: event.actorType ?? (event.userId ? 'user' : 'anonymous'),
actorRef: event.actorRef ?? null,
action: event.action,
targetType: event.targetType,
targetId: event.targetId ?? null,
@@ -34,6 +48,8 @@ export function createActivityRepo(db: Database): ActivityRepo {
id: activityEvents.id,
orgId: activityEvents.orgId,
userId: activityEvents.userId,
actorType: activityEvents.actorType,
actorRef: activityEvents.actorRef,
action: activityEvents.action,
targetType: activityEvents.targetType,
targetId: activityEvents.targetId,
@@ -54,13 +70,19 @@ export function createActivityRepo(db: Database): ActivityRepo {
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 ?? '', image: row.userImage ?? null },
user: {
id: row.userId,
name: row.userName ?? actorDisplayName(row.actorType, row.actorRef),
image: row.userImage ?? null,
},
}))
return { items, total }
@@ -90,6 +112,8 @@ export function createActivityRepo(db: Database): ActivityRepo {
id: activityEvents.id,
orgId: activityEvents.orgId,
userId: activityEvents.userId,
actorType: activityEvents.actorType,
actorRef: activityEvents.actorRef,
action: activityEvents.action,
targetType: activityEvents.targetType,
targetId: activityEvents.targetId,
@@ -112,13 +136,19 @@ export function createActivityRepo(db: Database): ActivityRepo {
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 ?? '', image: row.userImage ?? null },
user: {
id: row.userId,
name: row.userName ?? actorDisplayName(row.actorType, row.actorRef),
image: row.userImage ?? null,
},
orgName: row.orgName ?? null,
}))
@@ -147,7 +177,10 @@ export function createActivityRepo(db: Database): ActivityRepo {
])
return {
items: rows,
items: rows.map((row) => ({
...row,
actorType: normalizeActorType(row.actorType),
})),
total: countRows[0]?.count ?? 0,
page,
pageSize,
+825 -4
View File
@@ -1,9 +1,19 @@
import { isPersonalOrgLike } from '@shared/org-slugs'
import type { AdminStatsPoint } from '@shared/types'
import type {
AdminDashboardGrowthStats,
AdminDashboardOverviewStats,
AdminDashboardRankingStats,
AdminDashboardSharingStats,
AdminDashboardStorageStats,
AdminDashboardTrafficStats,
AdminStatsDelta,
AdminStatsPoint,
AdminTopShare,
} from '@shared/types'
import type { SQL } from 'drizzle-orm'
import { and, count, desc, eq, gt, gte, inArray, isNull, sql } from 'drizzle-orm'
import { and, count, desc, eq, gt, gte, inArray, isNull, lte, sql } from 'drizzle-orm'
import type { SQLiteTable } from 'drizzle-orm/sqlite-core'
import { organization, user } from '../../db/auth-schema'
import { account, organization, session, user } from '../../db/auth-schema'
import {
activityEvents,
backgroundJobs,
@@ -11,20 +21,34 @@ import {
downloaders,
downloadTasks,
matters,
orgQuotas,
shares,
siteInvitations,
storages,
webhookEvents,
} from '../../db/schema'
import type { Database } from '../../platform/interface'
import type { AdminCoreStatsBase, AdminDetailedStatsBase, AdminStatsRepo } from '../../usecases/ports'
import type {
AdminCoreStatsBase,
AdminDetailedStatsBase,
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'])
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),
getDashboardTrafficStats: (now, range) => getDashboardTrafficStats(db, now, range),
getDashboardSharingStats: (now, range) => getDashboardSharingStats(db, now, range),
getDashboardRankingStats: (now, range) => getDashboardRankingStats(db, now, range),
}
}
@@ -232,6 +256,7 @@ async function buildTrends(db: Database, start: Date, now: Date, periodDays: num
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>()
@@ -482,6 +507,802 @@ async function getCloudReportReliability(db: Database): Promise<AdminDetailedSta
}
}
async function getDashboardOverviewStats(
db: Database,
now: Date,
range: AdminStatsDateRange,
): Promise<AdminDashboardOverviewStats> {
const previous = previousRange(range)
const [
totalUsers,
newUsers,
previousNewUsers,
activeUsers,
previousActiveUsers,
quotas,
traffic,
previousTraffic,
sharing,
] = await Promise.all([
countRows(db, user),
countRowsWhere(db, user, and(gte(user.createdAt, range.from), lte(user.createdAt, range.to))),
countRowsWhere(db, user, and(gte(user.createdAt, previous.from), lte(user.createdAt, previous.to))),
countActiveUsers(db, range),
countActiveUsers(db, previous),
getQuotaTotals(db),
getTrafficTotals(db, range),
getTrafficTotals(db, previous),
getSharingEventTotals(db, range),
])
const [trendNewUsers, activeByDay, uploadByDay, downloadByDay] = await Promise.all([
getNewUsersByDay(db, range),
getActiveUsersByDay(db, range),
getActivityBytesByDay(db, range, ['upload_confirm']),
getDownloadBytesByDay(db, range),
])
const storageBase = quotas.usedBytes - sumMap(uploadByDay)
let runningStorage = storageBase
const trends = createDateBuckets(range).map((date) => {
runningStorage += uploadByDay.get(date) ?? 0
return {
date,
newUsers: trendNewUsers.get(date) ?? 0,
activeUsers: activeByDay.get(date)?.size ?? 0,
storageUsedBytes: Math.max(0, runningStorage),
uploadBytes: uploadByDay.get(date) ?? 0,
downloadBytes: downloadByDay.get(date) ?? 0,
}
})
return {
...statsFrame(now, range),
totals: {
users: totalUsers,
newUsers: delta(newUsers, previousNewUsers),
activeUsers: delta(activeUsers, previousActiveUsers),
storageUsedBytes: quotas.usedBytes,
storageQuotaBytes: quotas.quotaBytes,
trafficBytes: delta(
traffic.uploadBytes + traffic.downloadBytes,
previousTraffic.uploadBytes + previousTraffic.downloadBytes,
),
uploadBytes: delta(traffic.uploadBytes, previousTraffic.uploadBytes),
downloadBytes: delta(traffic.downloadBytes, previousTraffic.downloadBytes),
activeShares: sharing.activeShares,
shareViews: delta(sharing.views, (await getSharingEventTotals(db, previous)).views),
shareDownloads: delta(sharing.downloads, (await getSharingEventTotals(db, previous)).downloads),
},
trends,
}
}
async function getDashboardGrowthStats(
db: Database,
now: Date,
range: AdminStatsDateRange,
): Promise<AdminDashboardGrowthStats> {
const previous = previousRange(range)
const [usersRows, newUsers, previousNewUsers, activeUsers, previousActiveUsers, activeByDay, registrationSources] =
await Promise.all([
db
.select({ id: user.id, emailVerified: user.emailVerified, banned: user.banned, createdAt: user.createdAt })
.from(user),
countRowsWhere(db, user, and(gte(user.createdAt, range.from), lte(user.createdAt, range.to))),
countRowsWhere(db, user, and(gte(user.createdAt, previous.from), lte(user.createdAt, previous.to))),
countActiveUsers(db, range),
countActiveUsers(db, previous),
getRollingActiveUserTrend(db, range),
getRegistrationSources(db, range),
])
const activeLast30 = await activeUserIds(db, { from: daysAgo(now, 30), to: now })
const totalUsers = usersRows.length
const verifiedUsers = usersRows.filter((row) => row.emailVerified).length
const bannedUsers = usersRows.filter((row) => row.banned).length
const unverifiedUsers = usersRows.filter((row) => !row.emailVerified && !row.banned).length
const silentUsers = usersRows.filter((row) => row.emailVerified && !row.banned && !activeLast30.has(row.id)).length
const normalUsers = Math.max(0, totalUsers - unverifiedUsers - bannedUsers - silentUsers)
const newUsersByDay = await getNewUsersByDay(db, range)
let totalAtStart = usersRows.filter((row) => row.createdAt < range.from).length
const userScaleTrend = createDateBuckets(range).map((date) => {
const dailyNew = newUsersByDay.get(date) ?? 0
totalAtStart += dailyNew
return { date, newUsers: dailyNew, totalUsers: totalAtStart }
})
return {
...statsFrame(now, range),
summary: {
totalUsers,
newUsers: delta(newUsers, previousNewUsers),
activeUsers: delta(activeUsers, previousActiveUsers),
verifiedUsers,
bannedUsers,
silentUsers,
},
userScaleTrend,
activeUserTrend: activeByDay,
userStatus: percentRows([
{ name: 'normal', value: normalUsers },
{ name: 'unverified', value: unverifiedUsers },
{ name: 'banned', value: bannedUsers },
{ name: 'silent', value: silentUsers },
]),
registrationSources,
}
}
async function getDashboardStorageStats(
db: Database,
now: Date,
range: AdminStatsDateRange,
): Promise<AdminDashboardStorageStats> {
const previous = previousRange(range)
const [quotas, files, newFiles, previousNewFiles, uploadBytes, previousUploadBytes, typeBreakdown] =
await Promise.all([
getQuotaTotals(db),
db
.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))),
countRowsWhere(
db,
matters,
and(
eq(matters.status, 'active'),
eq(matters.dirtype, 0),
gte(matters.createdAt, range.from),
lte(matters.createdAt, range.to),
),
),
countRowsWhere(
db,
matters,
and(
eq(matters.status, 'active'),
eq(matters.dirtype, 0),
gte(matters.createdAt, previous.from),
lte(matters.createdAt, previous.to),
),
),
getActivityBytesTotal(db, range, ['upload_confirm']),
getActivityBytesTotal(db, previous, ['upload_confirm']),
getStorageByType(db),
])
const uploadByDay = await getActivityBytesByDay(db, range, ['upload_confirm'])
const newFilesByDay = await getMatterCreatesByDay(db, range)
const storageBase = quotas.usedBytes - sumMap(uploadByDay)
let runningStorage = storageBase
const storageTrend = createDateBuckets(range).map((date) => {
runningStorage += uploadByDay.get(date) ?? 0
return {
date,
usedBytes: Math.max(0, runningStorage),
newBytes: uploadByDay.get(date) ?? 0,
newFiles: newFilesByDay.get(date) ?? 0,
}
})
const fileItems = files.map((row) => ({ bytes: row.size ?? 0, createdAt: row.createdAt }))
const coldCutoff = daysAgo(now, 90)
const coldFileBytes = fileItems.filter((row) => row.createdAt < coldCutoff).reduce((sum, row) => sum + row.bytes, 0)
return {
...statsFrame(now, range),
summary: {
storageUsedBytes: quotas.usedBytes,
quotaBytes: quotas.quotaBytes,
fileCount: files.length,
newFiles: delta(newFiles, previousNewFiles),
newBytes: delta(uploadBytes, previousUploadBytes),
coldFileBytes,
},
storageTrend,
typeBreakdown: percentByBytes(
typeBreakdown.map((row) => ({ name: row.type, bytes: row.bytes, files: row.files })),
).map(({ name, ...row }) => ({
type: name,
...row,
})),
sizeBreakdown: fileBucketBreakdown(fileItems, [
{ name: '<10MB', max: 10 * 1024 * 1024 },
{ name: '10-100MB', max: 100 * 1024 * 1024 },
{ name: '100MB-1GB', max: 1024 * 1024 * 1024 },
{ name: '>1GB', max: Number.POSITIVE_INFINITY },
]),
ageBreakdown: ageBucketBreakdown(fileItems, now),
}
}
async function getDashboardTrafficStats(
db: Database,
now: Date,
range: AdminStatsDateRange,
): Promise<AdminDashboardTrafficStats> {
const previous = previousRange(range)
const [
traffic,
previousTraffic,
uploadByDay,
downloadByDay,
uploadRequestsByDay,
downloadRequestsByDay,
cloudRows,
downloadRows,
] = await Promise.all([
getTrafficTotals(db, range),
getTrafficTotals(db, previous),
getActivityBytesByDay(db, range, ['upload_confirm']),
getDownloadBytesByDay(db, range),
getActivityCountByDay(db, range, ['upload_confirm']),
getDownloadRequestsByDay(db, range),
getCloudTrafficRows(db, range),
getDownloadIssueRows(db, range),
])
const sourceRows = new Map<string, { name: string; bytes: number; requests: number }>()
if (traffic.uploadBytes > 0 || traffic.uploadRequests > 0) {
sourceRows.set('upload', { name: 'upload', bytes: traffic.uploadBytes, requests: traffic.uploadRequests })
}
for (const row of downloadRows) {
const item = sourceRows.get(row.source) ?? { name: row.source, bytes: 0, requests: 0 }
item.bytes += row.bytes
item.requests += 1
sourceRows.set(row.source, item)
}
for (const row of cloudRows) {
if (isAuditedDownloadSource(row.source) || row.status === 'blocked') continue
const item = sourceRows.get(row.source) ?? { name: row.source, bytes: 0, requests: 0 }
item.bytes += row.bytes
item.requests += 1
sourceRows.set(row.source, item)
}
const statusRows = countBy(cloudRows, (row) => row.status)
const trafficTrend = createDateBuckets(range).map((date) => ({
date,
uploadBytes: uploadByDay.get(date) ?? 0,
downloadBytes: downloadByDay.get(date) ?? 0,
requests: (uploadRequestsByDay.get(date) ?? 0) + (downloadRequestsByDay.get(date) ?? 0),
}))
const totalRequests = traffic.uploadRequests + traffic.downloadRequests
const blockedDownloads = cloudRows.filter((row) => row.status === 'blocked').length
const issuedDownloads = Math.max(0, traffic.downloadRequests - blockedDownloads)
return {
...statsFrame(now, range),
summary: {
totalBytes: delta(
traffic.uploadBytes + traffic.downloadBytes,
previousTraffic.uploadBytes + previousTraffic.downloadBytes,
),
requestCount: delta(totalRequests, previousTraffic.uploadRequests + previousTraffic.downloadRequests),
issuedDownloads,
blockedDownloads,
issueRate: percent(issuedDownloads, issuedDownloads + blockedDownloads),
peakDailyBytes: Math.max(0, ...trafficTrend.map((row) => row.uploadBytes + row.downloadBytes)),
},
trafficTrend,
sourceBreakdown: percentRows([...sourceRows.values()], (row) => row.bytes),
issueStatus: percentRows(
[...statusRows.entries()].map(([status, countValue]) => ({ status, name: status, value: countValue })),
).map(({ name, value, percent: pct }) => ({ status: name, count: value, percent: pct })),
bandwidthTrend: trafficTrend.map((row) => ({ date: row.date, bytes: row.uploadBytes + row.downloadBytes })),
}
}
async function getDashboardSharingStats(
db: Database,
now: Date,
range: AdminStatsDateRange,
): Promise<AdminDashboardSharingStats> {
const previous = previousRange(range)
const [sharing, previousSharing, topShares, shareRows, saveCount, previousSaveCount] = await Promise.all([
getSharingEventTotals(db, range),
getSharingEventTotals(db, previous),
getTopSharesWithPercent(db),
db.select({ kind: shares.kind, status: shares.status, createdAt: shares.createdAt }).from(shares),
countActivityEvents(db, range, ['save_from_share']),
countActivityEvents(db, previous, ['save_from_share']),
])
const [viewsByDay, downloadsByDay, savesByDay] = await Promise.all([
getActivityCountByDay(db, range, ['share_view']),
getActivityCountByDay(db, range, ['share_download']),
getActivityCountByDay(db, range, ['save_from_share']),
])
const trend = createDateBuckets(range).map((date) => ({
date,
views: viewsByDay.get(date) ?? 0,
downloads: downloadsByDay.get(date) ?? 0,
saves: savesByDay.get(date) ?? 0,
}))
const createdInRange = shareRows.filter((row) => row.createdAt >= range.from && row.createdAt <= range.to).length
const createdPrevious = shareRows.filter(
(row) => row.createdAt >= previous.from && row.createdAt <= previous.to,
).length
const activeShares = shareRows.filter((row) => row.status === 'active').length
const typeCounts = countBy(
shareRows.filter((row) => row.createdAt >= range.from && row.createdAt <= range.to),
(row) => row.kind,
)
return {
...statsFrame(now, range),
summary: {
activeShares,
createdShares: delta(createdInRange, createdPrevious),
views: delta(sharing.views, previousSharing.views),
downloads: delta(sharing.downloads, previousSharing.downloads),
saves: delta(saveCount, previousSaveCount),
downloadConversionRate: percent(sharing.downloads, sharing.views),
},
funnel: percentRows([
{ name: 'views', value: sharing.views },
{ name: 'password_passed', value: sharing.passwordPasses },
{ name: 'downloads', value: sharing.downloads },
{ name: 'saved_to_drive', value: saveCount },
]),
trend,
typeBreakdown: percentRows([...typeCounts.entries()].map(([name, value]) => ({ name, value }))),
sourceBreakdown: percentRows([
{ name: 'landing_share', value: sharing.downloads },
{ name: 'save_to_drive', value: saveCount },
]),
topShares,
}
}
async function getDashboardRankingStats(
db: Database,
now: Date,
range: AdminStatsDateRange,
): Promise<AdminDashboardRankingStats> {
const [topShares, topSpaces, storageByType] = await Promise.all([
getTopSharesWithPercent(db),
getUsageBySpaceRows(db),
getStorageByType(db),
])
return {
...statsFrame(now, range),
topShares,
topSpaces,
storageByType,
}
}
function statsFrame(now: Date, range: AdminStatsDateRange) {
return { generatedAt: now.toISOString(), from: range.from.toISOString(), to: range.to.toISOString() }
}
function previousRange(range: AdminStatsDateRange): AdminStatsDateRange {
const durationMs = Math.max(0, range.to.getTime() - range.from.getTime())
const to = new Date(range.from.getTime() - 1)
return { from: new Date(to.getTime() - durationMs), to }
}
function delta(value: number, previousValue: number): AdminStatsDelta {
return { value, previousValue, changePercent: percent(value - previousValue, previousValue) }
}
function createDateBuckets(range: AdminStatsDateRange): string[] {
const start = startOfDay(range.from)
const end = startOfDay(range.to)
const dates: string[] = []
for (const date = new Date(start); date <= end; date.setUTCDate(date.getUTCDate() + 1)) {
dates.push(dayKey(date))
}
return dates
}
async function getQuotaTotals(db: Database): Promise<{ usedBytes: number; quotaBytes: number }> {
const rows = await db
.select({
usedBytes: sql<number>`COALESCE(SUM(${orgQuotas.used}), 0)`,
quotaBytes: sql<number>`COALESCE(SUM(${orgQuotas.quota}), 0)`,
})
.from(orgQuotas)
return { usedBytes: toNumber(rows[0]?.usedBytes), quotaBytes: toNumber(rows[0]?.quotaBytes) }
}
async function countActiveUsers(db: Database, range: AdminStatsDateRange): Promise<number> {
return (await activeUserIds(db, range)).size
}
async function activeUserIds(db: Database, range: AdminStatsDateRange): Promise<Set<string>> {
const [activityRows, sessionRows] = await Promise.all([
db
.select({ userId: activityEvents.userId })
.from(activityEvents)
.where(and(gte(activityEvents.createdAt, range.from), lte(activityEvents.createdAt, range.to))),
db
.select({ userId: session.userId })
.from(session)
.where(and(gte(session.updatedAt, range.from), lte(session.updatedAt, range.to))),
])
const ids = new Set<string>()
for (const row of activityRows) if (row.userId) ids.add(row.userId)
for (const row of sessionRows) ids.add(row.userId)
return ids
}
async function getNewUsersByDay(db: Database, range: AdminStatsDateRange): Promise<Map<string, number>> {
const rows = await db
.select({ createdAt: user.createdAt })
.from(user)
.where(and(gte(user.createdAt, range.from), lte(user.createdAt, range.to)))
const byDay = new Map<string, number>()
for (const row of rows) incrementMap(byDay, dayKey(row.createdAt), 1)
return byDay
}
async function getActiveUsersByDay(db: Database, range: AdminStatsDateRange): Promise<Map<string, Set<string>>> {
const [activityRows, sessionRows] = await Promise.all([
db
.select({ userId: activityEvents.userId, createdAt: activityEvents.createdAt })
.from(activityEvents)
.where(and(gte(activityEvents.createdAt, range.from), lte(activityEvents.createdAt, range.to))),
db
.select({ userId: session.userId, updatedAt: session.updatedAt })
.from(session)
.where(and(gte(session.updatedAt, range.from), lte(session.updatedAt, range.to))),
])
const byDay = new Map<string, Set<string>>()
for (const row of activityRows) {
if (row.userId) addSetMap(byDay, dayKey(row.createdAt), row.userId)
}
for (const row of sessionRows) addSetMap(byDay, dayKey(row.updatedAt), row.userId)
return byDay
}
async function getRollingActiveUserTrend(
db: Database,
range: AdminStatsDateRange,
): Promise<Array<{ date: string; dau: number; wau: number; mau: number }>> {
const extendedRange = { from: daysAgo(range.from, 29), to: range.to }
const [activityRows, sessionRows] = await Promise.all([
db
.select({ userId: activityEvents.userId, createdAt: activityEvents.createdAt })
.from(activityEvents)
.where(and(gte(activityEvents.createdAt, extendedRange.from), lte(activityEvents.createdAt, extendedRange.to))),
db
.select({ userId: session.userId, updatedAt: session.updatedAt })
.from(session)
.where(and(gte(session.updatedAt, extendedRange.from), lte(session.updatedAt, extendedRange.to))),
])
const records: Array<{ userId: string; at: Date }> = []
for (const row of activityRows) if (row.userId) records.push({ userId: row.userId, at: row.createdAt })
for (const row of sessionRows) records.push({ userId: row.userId, at: row.updatedAt })
return createDateBuckets(range).map((dateKeyValue) => {
const dayEnd = new Date(`${dateKeyValue}T23:59:59.999Z`)
const dauStart = new Date(`${dateKeyValue}T00:00:00.000Z`)
const wauStart = daysAgo(dayEnd, 6)
const mauStart = daysAgo(dayEnd, 29)
return {
date: dateKeyValue,
dau: distinctUsersInWindow(records, dauStart, dayEnd),
wau: distinctUsersInWindow(records, wauStart, dayEnd),
mau: distinctUsersInWindow(records, mauStart, dayEnd),
}
})
}
async function getRegistrationSources(
db: Database,
range: AdminStatsDateRange,
): Promise<Array<{ name: string; value: number; percent: number }>> {
const newUsers = await db
.select({ id: user.id })
.from(user)
.where(and(gte(user.createdAt, range.from), lte(user.createdAt, range.to)))
if (newUsers.length === 0) return []
const userIds = newUsers.map((row) => row.id)
const accounts = await db
.select({ userId: account.userId, providerId: account.providerId })
.from(account)
.where(inArray(account.userId, userIds))
const seen = new Set<string>()
const counts = new Map<string, number>()
for (const row of accounts) {
if (seen.has(row.userId)) continue
seen.add(row.userId)
incrementMap(counts, row.providerId || 'unknown', 1)
}
const direct = userIds.length - seen.size
if (direct > 0) counts.set('direct', direct)
return percentRows([...counts.entries()].map(([name, value]) => ({ name, value })))
}
async function getActivityBytesTotal(db: Database, range: AdminStatsDateRange, actions: string[]): Promise<number> {
const rows = await getActivityMetadataRows(db, range, actions)
return rows.reduce((sum, row) => sum + metadataNumber(row.metadata, 'bytes'), 0)
}
async function getActivityBytesByDay(
db: Database,
range: AdminStatsDateRange,
actions: string[],
): Promise<Map<string, number>> {
const rows = await getActivityMetadataRows(db, range, actions)
const byDay = new Map<string, number>()
for (const row of rows) incrementMap(byDay, dayKey(row.createdAt), metadataNumber(row.metadata, 'bytes'))
return byDay
}
async function getActivityCountByDay(
db: Database,
range: AdminStatsDateRange,
actions: string[],
): Promise<Map<string, number>> {
const rows = await getActivityMetadataRows(db, range, actions)
const byDay = new Map<string, number>()
for (const row of rows) incrementMap(byDay, dayKey(row.createdAt), 1)
return byDay
}
async function countActivityEvents(db: Database, range: AdminStatsDateRange, actions: string[]): Promise<number> {
if (actions.length === 0) return 0
return countRowsWhere(
db,
activityEvents,
and(
inArray(activityEvents.action, actions),
gte(activityEvents.createdAt, range.from),
lte(activityEvents.createdAt, range.to),
),
)
}
async function getActivityMetadataRows(db: Database, range: AdminStatsDateRange, actions: string[]) {
if (actions.length === 0) return []
return db
.select({ action: activityEvents.action, metadata: activityEvents.metadata, createdAt: activityEvents.createdAt })
.from(activityEvents)
.where(
and(
inArray(activityEvents.action, actions),
gte(activityEvents.createdAt, range.from),
lte(activityEvents.createdAt, range.to),
),
)
}
async function getMatterCreatesByDay(db: Database, range: AdminStatsDateRange): Promise<Map<string, number>> {
const rows = await db
.select({ createdAt: matters.createdAt })
.from(matters)
.where(
and(
eq(matters.status, 'active'),
eq(matters.dirtype, 0),
gte(matters.createdAt, range.from),
lte(matters.createdAt, range.to),
),
)
const byDay = new Map<string, number>()
for (const row of rows) incrementMap(byDay, dayKey(row.createdAt), 1)
return byDay
}
async function getCloudTrafficRows(db: Database, range: AdminStatsDateRange) {
return db
.select({
source: cloudTrafficReports.source,
bytes: cloudTrafficReports.bytes,
status: cloudTrafficReports.status,
createdAt: cloudTrafficReports.createdAt,
})
.from(cloudTrafficReports)
.where(and(gte(cloudTrafficReports.createdAt, range.from), lte(cloudTrafficReports.createdAt, range.to)))
}
async function getDownloadIssueRows(
db: Database,
range: AdminStatsDateRange,
): Promise<Array<{ source: string; bytes: number; createdAt: Date }>> {
const rows = await getActivityMetadataRows(db, range, DOWNLOAD_ACTIVITY_ACTIONS)
return rows.map((row) => {
const metadata = parseMetadata(row.metadata)
return {
source:
typeof metadata.source === 'string'
? metadata.source
: row.action === 'object_download'
? 'object_download'
: 'landing_share',
bytes: toNumber(metadata.bytes),
createdAt: row.createdAt,
}
})
}
async function getDownloadBytesByDay(db: Database, range: AdminStatsDateRange): Promise<Map<string, number>> {
const [activityRows, cloudRows] = await Promise.all([getDownloadIssueRows(db, range), getCloudTrafficRows(db, range)])
const byDay = new Map<string, number>()
for (const row of activityRows) incrementMap(byDay, dayKey(row.createdAt), row.bytes)
for (const row of cloudRows) {
if (isAuditedDownloadSource(row.source) || row.status === 'blocked') continue
incrementMap(byDay, dayKey(row.createdAt), row.bytes)
}
return byDay
}
async function getDownloadRequestsByDay(db: Database, range: AdminStatsDateRange): Promise<Map<string, number>> {
const [activityRows, cloudRows] = await Promise.all([getDownloadIssueRows(db, range), getCloudTrafficRows(db, range)])
const byDay = new Map<string, number>()
for (const row of activityRows) incrementMap(byDay, dayKey(row.createdAt), 1)
for (const row of cloudRows) {
if (isAuditedDownloadSource(row.source) && row.status !== 'blocked') continue
incrementMap(byDay, dayKey(row.createdAt), 1)
}
return byDay
}
async function getTrafficTotals(
db: Database,
range: AdminStatsDateRange,
): Promise<{ uploadBytes: number; uploadRequests: number; downloadBytes: number; downloadRequests: number }> {
const [uploadBytes, uploadRequests, activityDownloadRows, cloudRows] = await Promise.all([
getActivityBytesTotal(db, range, ['upload_confirm']),
countActivityEvents(db, range, ['upload_confirm']),
getDownloadIssueRows(db, range),
getCloudTrafficRows(db, range),
])
const cloudDownloadRows = cloudRows.filter((row) => !isAuditedDownloadSource(row.source) && row.status !== 'blocked')
const blockedDownloadRows = cloudRows.filter((row) => row.status === 'blocked')
return {
uploadBytes,
uploadRequests,
downloadBytes:
activityDownloadRows.reduce((sum, row) => sum + row.bytes, 0) +
cloudDownloadRows.reduce((sum, row) => sum + row.bytes, 0),
downloadRequests: activityDownloadRows.length + cloudDownloadRows.length + blockedDownloadRows.length,
}
}
function isAuditedDownloadSource(source: string): boolean {
return AUDITED_DOWNLOAD_SOURCES.has(source)
}
async function getSharingEventTotals(
db: Database,
range: AdminStatsDateRange,
): Promise<{ activeShares: number; views: number; passwordPasses: number; downloads: number }> {
const [activeShares, views, passwordPasses, downloads] = await Promise.all([
countRowsWhere(db, shares, eq(shares.status, 'active')),
countActivityEvents(db, range, ['share_view']),
countActivityEvents(db, range, ['share_password_passed']),
countActivityEvents(db, range, ['share_download']),
])
return { activeShares, views, passwordPasses, downloads }
}
async function getTopSharesWithPercent(
db: Database,
): Promise<Array<AdminTopShare & { viewPercent: number; downloadPercent: number }>> {
const rows = await getTopShares(db)
const totalViews = rows.reduce((sum, row) => sum + row.views, 0)
const totalDownloads = rows.reduce((sum, row) => sum + row.downloads, 0)
return rows.map((row) => ({
...row,
viewPercent: percent(row.views, totalViews),
downloadPercent: percent(row.downloads, totalDownloads),
}))
}
async function getUsageBySpaceRows(db: Database): Promise<AdminDashboardRankingStats['topSpaces']> {
const rows = await db
.select({
orgId: orgQuotas.orgId,
usedBytes: orgQuotas.used,
quotaBytes: orgQuotas.quota,
orgName: organization.name,
slug: organization.slug,
metadata: organization.metadata,
})
.from(orgQuotas)
.leftJoin(organization, eq(organization.id, orgQuotas.orgId))
.orderBy(desc(orgQuotas.used))
.limit(8)
return rows.map((row) => ({
orgId: row.orgId,
orgName: row.orgName ?? row.orgId,
orgType: isPersonalOrgLike({ slug: row.slug ?? '', metadata: row.metadata ?? null }) ? 'personal' : 'team',
usedBytes: row.usedBytes,
quotaBytes: row.quotaBytes,
utilization: percent(row.usedBytes, row.quotaBytes),
}))
}
function percentRows<T extends { name: string }>(
rows: T[],
getValue: (row: T) => number = (row) => ('value' in row ? toNumber(row.value) : 0),
): Array<T & { value: number; percent: number }> {
const total = rows.reduce((sum, row) => sum + getValue(row), 0)
return rows.map((row) => {
const value = getValue(row)
return { ...row, value, percent: percent(value, total) }
})
}
function percentByBytes<T extends { name: string; bytes: number }>(rows: T[]): Array<T & { percent: number }> {
const total = rows.reduce((sum, row) => sum + row.bytes, 0)
return rows.map((row) => ({ ...row, percent: percent(row.bytes, total) }))
}
function fileBucketBreakdown(
files: Array<{ bytes: number }>,
buckets: Array<{ name: string; max: number }>,
): Array<{ name: string; bytes: number; files: number; percent: number }> {
const rows = buckets.map((bucket) => ({ name: bucket.name, bytes: 0, files: 0, max: bucket.max }))
for (const file of files) {
const bucket = rows.find((row, index) => file.bytes <= row.max && (index === 0 || file.bytes > rows[index - 1].max))
if (!bucket) continue
bucket.files += 1
bucket.bytes += file.bytes
}
return percentByBytes(rows.map(({ max: _max, ...row }) => row))
}
function ageBucketBreakdown(
files: Array<{ bytes: number; createdAt: Date }>,
now: Date,
): Array<{ name: string; bytes: number; files: number; percent: number }> {
const rows = [
{ name: '<30d', bytes: 0, files: 0 },
{ name: '30-90d', bytes: 0, files: 0 },
{ name: '90-180d', bytes: 0, files: 0 },
{ name: '>180d', bytes: 0, files: 0 },
]
for (const file of files) {
const ageDays = Math.floor((now.getTime() - file.createdAt.getTime()) / 86_400_000)
const bucket = ageDays < 30 ? rows[0] : ageDays < 90 ? rows[1] : ageDays < 180 ? rows[2] : rows[3]
bucket.files += 1
bucket.bytes += file.bytes
}
return percentByBytes(rows)
}
function metadataNumber(metadata: string | null, key: string): number {
const parsed = parseMetadata(metadata)
return toNumber(parsed[key])
}
function parseMetadata(metadata: string | null): Record<string, unknown> {
if (!metadata) return {}
try {
const parsed = JSON.parse(metadata)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {}
} catch {
return {}
}
}
function incrementMap(map: Map<string, number>, key: string, value: number): void {
map.set(key, (map.get(key) ?? 0) + value)
}
function addSetMap(map: Map<string, Set<string>>, key: string, value: string): void {
const set = map.get(key) ?? new Set<string>()
set.add(value)
map.set(key, set)
}
function sumMap(map: Map<string, number>): number {
let total = 0
for (const value of map.values()) total += value
return total
}
function countBy<T>(rows: T[], getKey: (row: T) => string): Map<string, number> {
const map = new Map<string, number>()
for (const row of rows) incrementMap(map, getKey(row), 1)
return map
}
function distinctUsersInWindow(records: Array<{ userId: string; at: Date }>, from: Date, to: Date): number {
const ids = new Set<string>()
for (const record of records) {
if (record.at >= from && record.at <= to) ids.add(record.userId)
}
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) {
+4 -1
View File
@@ -95,7 +95,9 @@ function quotaChangeAuditValues(event: CloudOrderQuotaChange): typeof activityEv
return {
id: nanoid(),
orgId: event.targetOrgId,
userId: event.customerId ?? 'cloud-store',
userId: null,
actorType: 'system',
actorRef: 'cloud-store',
action: `quota_order_${event.direction}`,
targetType: 'quota',
targetId: event.targetOrgId,
@@ -103,6 +105,7 @@ function quotaChangeAuditValues(event: CloudOrderQuotaChange): typeof activityEv
metadata: JSON.stringify({
eventId: event.eventId,
eventType: event.eventType,
customerId: event.customerId ?? null,
direction: event.direction,
storageBytes: event.storageBytes,
trafficBytes: event.trafficBytes,
+82 -10
View File
@@ -437,16 +437,88 @@ export const announcements = sqliteTable(
],
)
export const activityEvents = sqliteTable('activity_events', {
id: text('id').primaryKey(),
orgId: text('org_id').notNull(),
userId: text('user_id').notNull(),
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(),
export const activityEvents = sqliteTable(
'activity_events',
{
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(),
},
(t) => [
index('activity_events_org_created_idx').on(t.orgId, t.createdAt),
index('activity_events_user_created_idx').on(t.userId, t.createdAt),
index('activity_events_action_created_idx').on(t.action, t.createdAt),
index('activity_events_target_created_idx').on(t.targetType, t.targetId, t.createdAt),
],
)
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',
{
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_daily_bucket_metric_dim_uniq').on(
t.bucketStart,
t.orgId,
t.metricKey,
t.dimensionKey,
t.dimensionValue,
),
index('stats_rollups_daily_metric_bucket_idx').on(t.metricKey, t.bucketStart),
],
)
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(
@@ -68,6 +68,42 @@ describe('admin stats routes', () => {
expect(body.reliability.license.lastRefreshAt).toMatch(/^20\d{2}-/)
expect(body.trends.some((point) => point.remoteTasks > 0 || point.failedJobs > 0)).toBe(true)
})
it('returns traffic dashboard stats from audit-backed download events 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/traffic?from=2020-01-01T00:00:00.000Z&to=2100-01-01T00:00:00.000Z',
{
headers,
},
)
const body = (await res.json()) as {
summary: {
totalBytes: { value: number }
requestCount: { value: number }
issuedDownloads: number
blockedDownloads: number
}
sourceBreakdown: Array<{ name: string; bytes: number; requests: number }>
}
expect(res.status).toBe(200)
expect(body.summary.totalBytes.value).toBe(896)
expect(body.summary.requestCount.value).toBe(3)
expect(body.summary.issuedDownloads).toBe(2)
expect(body.summary.blockedDownloads).toBe(0)
expect(body.sourceBreakdown).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'upload', bytes: 128, requests: 1 }),
expect.objectContaining({ name: 'landing_share', bytes: 512, requests: 1 }),
expect.objectContaining({ name: 'object_download', bytes: 256, requests: 1 }),
]),
)
})
})
async function seedStatsFixture(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
@@ -106,6 +142,13 @@ async function seedStatsFixture(db: Awaited<ReturnType<typeof createTestApp>>['d
INSERT INTO activity_events (id, org_id, user_id, action, target_type, target_id, target_name, metadata, created_at)
VALUES ('activity-1', ${orgId}, ${userId}, 'upload', 'file', 'stats-file', 'report.pdf', NULL, ${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-upload-confirm', ${orgId}, ${userId}, 'user', 'upload_confirm', 'file', 'stats-file', 'report.pdf', '{"bytes":128,"source":"upload"}', ${nowSec}),
('activity-share-download', ${orgId}, NULL, 'anonymous', 'share_download', 'share', 'share-1', 'report.pdf', '{"bytes":512,"source":"landing_share","anonymous":true}', ${nowSec}),
('activity-object-download', ${orgId}, ${userId}, 'user', 'object_download', 'file', 'stats-file', 'report.pdf', '{"bytes":256,"source":"object_download"}', ${nowSec})
`)
await db.run(sql`
INSERT INTO downloaders (id, name, token_hash, token_jti, status, enabled, version, hostname, platform, arch, engine, capabilities, max_concurrent_tasks, current_tasks, download_bps, upload_bps, free_disk_bytes, created_by, last_heartbeat_at, created_at, updated_at)
VALUES ('downloader-1', 'Downloader One', 'hash', 'jti', 'online', 1, '1.0.0', 'host', 'linux', 'x64', 'http', '[]', 2, 0, 0, 0, 1000, ${userId}, ${now}, ${now}, ${now})
+127 -1
View File
@@ -2,7 +2,16 @@ import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
import { requireAdmin } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import { requireFeature } from '../middleware/require-feature'
import { getAdminCoreStats, getAdminDetailedStats } from '../usecases/admin-stats'
import {
getAdminCoreStats,
getAdminDashboardGrowthStats,
getAdminDashboardOverviewStats,
getAdminDashboardRankingStats,
getAdminDashboardSharingStats,
getAdminDashboardStorageStats,
getAdminDashboardTrafficStats,
getAdminDetailedStats,
} from '../usecases/admin-stats'
import { errorResponse, jsonContent } from './openapi'
const coreStatsSchema = z
@@ -139,6 +148,20 @@ const detailsQuerySchema = z.object({
periodDays: z.coerce.number().int().min(7).max(90).default(30),
})
const rangeQuerySchema = z.object({
from: z.string().datetime().optional(),
to: z.string().datetime().optional(),
})
const dashboardStatsSchema = z.any().openapi('AdminDashboardStats')
function parseRange(query: z.infer<typeof rangeQuerySchema>): { from?: Date; to?: Date } {
return {
from: query.from ? new Date(query.from) : undefined,
to: query.to ? new Date(query.to) : undefined,
}
}
const coreRoute = createRoute({
operationId: 'getAdminCoreStats',
summary: 'Get admin dashboard core stats',
@@ -166,9 +189,112 @@ const detailsRoute = createRoute({
},
})
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(dashboardStatsSchema, '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(dashboardStatsSchema, 'Admin dashboard growth stats'),
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(dashboardStatsSchema, 'Admin dashboard storage stats'),
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(dashboardStatsSchema, 'Admin dashboard traffic stats'),
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(dashboardStatsSchema, 'Admin dashboard sharing stats'),
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(dashboardStatsSchema, 'Admin dashboard ranking stats'),
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) => {
const { periodDays } = c.req.valid('query')
return c.json(await getAdminDetailedStats(c.get('deps'), { periodDays }), 200)
})
.openapi(overviewRoute, async (c) =>
c.json(await getAdminDashboardOverviewStats(c.get('deps'), parseRange(c.req.valid('query'))), 200),
)
.openapi(growthRoute, async (c) =>
c.json(await getAdminDashboardGrowthStats(c.get('deps'), parseRange(c.req.valid('query'))), 200),
)
.openapi(storageRoute, async (c) =>
c.json(await getAdminDashboardStorageStats(c.get('deps'), parseRange(c.req.valid('query'))), 200),
)
.openapi(trafficRoute, async (c) =>
c.json(await getAdminDashboardTrafficStats(c.get('deps'), parseRange(c.req.valid('query'))), 200),
)
.openapi(sharingRoute, async (c) =>
c.json(await getAdminDashboardSharingStats(c.get('deps'), parseRange(c.req.valid('query'))), 200),
)
.openapi(rankingRoute, async (c) =>
c.json(await getAdminDashboardRankingStats(c.get('deps'), parseRange(c.req.valid('query'))), 200),
)
+1
View File
@@ -437,6 +437,7 @@ const objects = app
const result = await getObject(c.get('deps'), {
orgId,
objectId: c.req.valid('param').id,
actorId: actorId(c),
cloudBaseUrl: cloudBaseUrl(c),
})
if (result.ok) {
+2 -1
View File
@@ -278,7 +278,8 @@ export const publicShares = pub
.openapi(verifyShareRoute, async (c) => {
const token = c.req.valid('param').token
const { password } = c.req.valid('json')
const out = await verifySharePassword(c.get('deps'), { token, password })
const viewerId = await readUserId(c)
const out = await verifySharePassword(c.get('deps'), { token, password, viewerId })
if (out.ok) {
setCookie(c, cookieName(token), 'ok', {
httpOnly: true,
@@ -579,7 +579,7 @@ describe('Share download audit events', () => {
assertNoSecrets(metaStr)
})
it('records share_download using creator as actor for anonymous download', async () => {
it('records share_download as anonymous without attributing it to the creator', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app) // seeds user and personal org
await insertStorage(db)
@@ -599,9 +599,11 @@ describe('Share download audit events', () => {
const evt = await getLatestActivity(db, 'share_download')
expect(evt).toBeDefined()
expect(evt?.userId).toBe(creatorId) // creator used as proxy for anonymous
expect(evt?.userId).toBeNull()
expect(evt?.actorType).toBe('anonymous')
const md = JSON.parse(evt?.metadata ?? '{}') as Record<string, unknown>
expect(md.anonymous).toBe(true)
expect(md.creatorId).toBe(creatorId)
assertNoSecrets(evt?.metadata ?? null)
})
})
+4 -2
View File
@@ -12,14 +12,16 @@ const auditEventSchema = z
.object({
id: z.string(),
orgId: z.string(),
userId: z.string(),
userId: z.string().nullable(),
actorType: z.enum(['user', 'anonymous', 'system', 'downloader']),
actorRef: z.string().nullable(),
action: z.string(),
targetType: z.string(),
targetId: z.string().nullable(),
targetName: z.string(),
metadata: z.string().nullable(),
createdAt: z.string(),
user: z.object({ id: z.string(), name: z.string(), image: z.string().nullable() }),
user: z.object({ id: z.string().nullable(), name: z.string(), image: z.string().nullable() }),
orgName: z.string().nullable(),
})
.openapi('AuditEvent')
+4 -2
View File
@@ -74,14 +74,16 @@ const activityEventSchema = z
.object({
id: z.string(),
orgId: z.string(),
userId: z.string(),
userId: z.string().nullable(),
actorType: z.enum(['user', 'anonymous', 'system', 'downloader']),
actorRef: z.string().nullable(),
action: z.string(),
targetType: z.string(),
targetId: z.string().nullable(),
targetName: z.string(),
metadata: z.string().nullable(),
createdAt: z.string(),
user: z.object({ id: z.string(), name: z.string(), image: z.string().nullable() }),
user: z.object({ id: z.string().nullable(), name: z.string(), image: z.string().nullable() }),
})
.openapi('ActivityEvent')
+53 -12
View File
@@ -284,18 +284,59 @@ const APP_SCHEMA_SQL = `
created_at INTEGER NOT NULL
);
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 NOT NULL,
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT,
target_name TEXT NOT NULL,
metadata TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS activity_events_org_id_idx ON activity_events(org_id);
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
);
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,
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_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 (
id TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
+84 -2
View File
@@ -1,7 +1,23 @@
import type { AdminCoreStats, AdminDetailedStats, AdminUsageBySpace } from '@shared/types'
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 { AdminStatsRepo, LicenseBindingRepo, QuotaRepo } from './ports'
import {
type AdminStatsDateRange,
type AdminStatsRepo,
badRequest,
type LicenseBindingRepo,
type QuotaRepo,
} from './ports'
import { listQuotaOverview } from './quota'
import { loadBindingState } from './site/licensing'
@@ -93,8 +109,74 @@ export async function getAdminDetailedStats(
}
}
export interface AdminStatsRangeInput {
from?: Date
to?: Date
}
export function normalizeStatsRange(input: AdminStatsRangeInput, now = new Date()): AdminStatsDateRange {
const to = input.to ?? now
const from = input.from ?? daysAgo(to, 29)
if (from > to) throw badRequest('from must be before to', 'INVALID_TIME_RANGE')
return { from, to }
}
export function getAdminDashboardOverviewStats(
deps: AdminStatsDeps,
input: AdminStatsRangeInput,
now = new Date(),
): Promise<AdminDashboardOverviewStats> {
return deps.adminStats.getDashboardOverviewStats(now, normalizeStatsRange(input, now))
}
export function getAdminDashboardGrowthStats(
deps: AdminStatsDeps,
input: AdminStatsRangeInput,
now = new Date(),
): Promise<AdminDashboardGrowthStats> {
return deps.adminStats.getDashboardGrowthStats(now, normalizeStatsRange(input, now))
}
export function getAdminDashboardStorageStats(
deps: AdminStatsDeps,
input: AdminStatsRangeInput,
now = new Date(),
): Promise<AdminDashboardStorageStats> {
return deps.adminStats.getDashboardStorageStats(now, normalizeStatsRange(input, now))
}
export function getAdminDashboardTrafficStats(
deps: AdminStatsDeps,
input: AdminStatsRangeInput,
now = new Date(),
): Promise<AdminDashboardTrafficStats> {
return deps.adminStats.getDashboardTrafficStats(now, normalizeStatsRange(input, now))
}
export function getAdminDashboardSharingStats(
deps: AdminStatsDeps,
input: AdminStatsRangeInput,
now = new Date(),
): Promise<AdminDashboardSharingStats> {
return deps.adminStats.getDashboardSharingStats(now, normalizeStatsRange(input, now))
}
export function getAdminDashboardRankingStats(
deps: AdminStatsDeps,
input: AdminStatsRangeInput,
now = new Date(),
): Promise<AdminDashboardRankingStats> {
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)
return date
}
+22 -10
View File
@@ -749,7 +749,7 @@ describe('object usecase', () => {
it('returns a folder without metering or presigning', async () => {
const presignDownload = vi.fn()
const { deps } = makeDeps({ matter: { get: async () => folder('f1') }, s3: { presignDownload } })
const out = await getObject(deps, { orgId: 'o1', objectId: 'f1', cloudBaseUrl: 'https://cloud' })
const out = await getObject(deps, { orgId: 'o1', objectId: 'f1', actorId: 'u1', cloudBaseUrl: 'https://cloud' })
expect(out).toEqual({ ok: true, matter: folder('f1') })
expect(meterDownloadTraffic).not.toHaveBeenCalled()
expect(presignDownload).not.toHaveBeenCalled()
@@ -757,21 +757,24 @@ describe('object usecase', () => {
it('returns not_found for a missing object', async () => {
const { deps } = makeDeps({ matter: { get: async () => null } })
const out = await getObject(deps, { orgId: 'o1', objectId: 'x', cloudBaseUrl: 'https://cloud' })
const out = await getObject(deps, { orgId: 'o1', objectId: 'x', actorId: 'u1', cloudBaseUrl: 'https://cloud' })
expectError(out, 404, 'Not found')
})
it('returns storage_not_found when a file has no storage row', async () => {
const { deps } = makeDeps({ matter: { get: async () => file('m1') }, storages: { get: async () => null } })
const out = await getObject(deps, { orgId: 'o1', objectId: 'm1', cloudBaseUrl: 'https://cloud' })
const out = await getObject(deps, { orgId: 'o1', objectId: 'm1', actorId: 'u1', cloudBaseUrl: 'https://cloud' })
expectError(out, 404, 'Storage not found')
})
it('meters egress then presigns the download URL for a file', async () => {
vi.mocked(meterDownloadTraffic).mockResolvedValue({ ok: true })
const presignDownload = vi.fn(async () => 'https://signed')
const { deps } = makeDeps({ matter: { get: async () => file('m1', { size: 100 }) }, s3: { presignDownload } })
const out = await getObject(deps, { orgId: 'o1', objectId: 'm1', cloudBaseUrl: 'https://cloud' })
const { deps, record } = makeDeps({
matter: { get: async () => file('m1', { size: 100 }) },
s3: { presignDownload },
})
const out = await getObject(deps, { orgId: 'o1', objectId: 'm1', actorId: 'u1', cloudBaseUrl: 'https://cloud' })
expect(out).toMatchObject({ ok: true, downloadUrl: 'https://signed' })
expect(meterDownloadTraffic).toHaveBeenCalledWith(
deps,
@@ -783,13 +786,22 @@ describe('object usecase', () => {
sourceId: 'm1',
}),
)
expect(record).toHaveBeenCalledWith({
orgId: 'o1',
userId: 'u1',
action: 'object_download',
targetType: 'file',
targetId: 'm1',
targetName: 'm1.txt',
metadata: expect.objectContaining({ bytes: 100, source: 'object_download', status: 'issued' }),
})
})
it('maps a quota_exceeded metering outcome and never presigns', async () => {
vi.mocked(meterDownloadTraffic).mockResolvedValue({ ok: false, reason: 'quota_exceeded' })
const presignDownload = vi.fn()
const { deps } = makeDeps({ matter: { get: async () => file('m1') }, s3: { presignDownload } })
const out = await getObject(deps, { orgId: 'o1', objectId: 'm1', cloudBaseUrl: 'https://cloud' })
const out = await getObject(deps, { orgId: 'o1', objectId: 'm1', actorId: 'u1', cloudBaseUrl: 'https://cloud' })
expectError(out, 422, 'Traffic quota exceeded', 'QUOTA_EXCEEDED')
expect(presignDownload).not.toHaveBeenCalled()
})
@@ -797,7 +809,7 @@ describe('object usecase', () => {
it('maps an insufficient_credits metering outcome', async () => {
vi.mocked(meterDownloadTraffic).mockResolvedValue({ ok: false, reason: 'insufficient_credits' })
const { deps } = makeDeps({ matter: { get: async () => file('m1') } })
const out = await getObject(deps, { orgId: 'o1', objectId: 'm1', cloudBaseUrl: 'https://cloud' })
const out = await getObject(deps, { orgId: 'o1', objectId: 'm1', actorId: 'u1', cloudBaseUrl: 'https://cloud' })
expectError(out, 402, 'Insufficient credits', 'INSUFFICIENT_CREDITS')
expect((out as { ok: false; error: AppError }).error.meta.metadata).toEqual({ resource: 'storage_egress' })
})
@@ -814,9 +826,9 @@ describe('object usecase', () => {
},
},
})
await expect(getObject(deps, { orgId: 'o1', objectId: 'm1', cloudBaseUrl: 'https://cloud' })).rejects.toThrow(
'sign failed',
)
await expect(
getObject(deps, { orgId: 'o1', objectId: 'm1', actorId: 'u1', cloudBaseUrl: 'https://cloud' }),
).rejects.toThrow('sign failed')
expect(refundTraffic).toHaveBeenCalledWith('o1', 250)
})
})
+35 -4
View File
@@ -435,9 +435,9 @@ export type GetObjectOutcome =
export async function getObject(
deps: Pick<
Deps,
'matter' | 'storages' | 's3' | 'quota' | 'licenseBinding' | 'licensingCloud' | 'cloudTrafficReports'
'matter' | 'storages' | 's3' | 'quota' | 'licenseBinding' | 'licensingCloud' | 'cloudTrafficReports' | 'activity'
>,
params: { orgId: string; objectId: string; cloudBaseUrl: string },
params: { orgId: string; objectId: string; actorId: string; cloudBaseUrl: string },
): Promise<GetObjectOutcome> {
const matter = await deps.matter.get(params.objectId, params.orgId)
// Live objects only — a trashed object is fetched via GET /trash/objects/{id}.
@@ -472,6 +472,22 @@ export async function getObject(
await deps.quota.refundTraffic(params.orgId, bytes)
throw error
}
await deps.activity.record({
orgId: params.orgId,
userId: params.actorId,
action: 'object_download',
targetType: 'file',
targetId: matter.id,
targetName: matter.name,
metadata: {
status: 'issued',
source: 'object_download',
matterId: matter.id,
storageId: matter.storageId,
bytes,
matterType: matter.type,
},
})
return { ok: true, matter, downloadUrl }
}
@@ -788,6 +804,13 @@ export async function confirmUpload(
targetType: 'file',
targetId: confirmed.id,
targetName: confirmed.name,
metadata: {
bytes,
storageId: confirmed.storageId,
matterId: confirmed.id,
matterType: confirmed.type,
onConflict: opts.onConflict ?? 'fail',
},
})
}
@@ -900,7 +923,7 @@ export type SaveToDriveDeps = {
}
export interface SaveShareInput {
share: { id: string }
share: { id: string; creatorId: string }
matter: Matter
currentUserId: string
targetOrgId: string
@@ -1110,6 +1133,14 @@ export async function saveShareToDrive(deps: SaveToDriveDeps, input: SaveShareIn
return copyMatterToOrg(deps, {
...rest,
sourceMatter,
activity: { action: 'save_from_share', metadata: { sourceShareId: share.id } },
activity: {
action: 'save_from_share',
metadata: {
sourceShareId: share.id,
sourceMatterId: sourceMatter.id,
creatorId: share.creatorId,
bytes: sourceMatter.size ?? 0,
},
},
})
}
+9 -3
View File
@@ -2,9 +2,13 @@
// No drizzle here — the port is what usecases and http see; the adapter maps rows
// into these shapes.
export type ActivityActorType = 'user' | 'anonymous' | 'system' | 'downloader'
export interface RecordActivityInput {
orgId: string
userId: string
userId?: string | null
actorType?: ActivityActorType
actorRef?: string | null
action: string
targetType: string
targetId?: string
@@ -15,7 +19,9 @@ export interface RecordActivityInput {
export interface ActivityEvent {
id: string
orgId: string
userId: string
userId: string | null
actorType: ActivityActorType
actorRef: string | null
action: string
targetType: string
targetId: string | null
@@ -25,7 +31,7 @@ export interface ActivityEvent {
}
export interface ActivityEventWithUser extends ActivityEvent {
user: { id: string; name: string; image: string | null }
user: { id: string | null; name: string; image: string | null }
}
export interface AdminAuditEventWithOrg extends ActivityEventWithUser {
+17
View File
@@ -2,6 +2,12 @@ import type {
AdminBackgroundJobFailure,
AdminCoreStats,
AdminCountByStatus,
AdminDashboardGrowthStats,
AdminDashboardOverviewStats,
AdminDashboardRankingStats,
AdminDashboardSharingStats,
AdminDashboardStorageStats,
AdminDashboardTrafficStats,
AdminDetailedStats,
AdminDownloaderHealth,
AdminDownloadFailureReason,
@@ -39,4 +45,15 @@ export interface AdminDetailedStatsBase {
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>
getDashboardTrafficStats(now: Date, range: AdminStatsDateRange): Promise<AdminDashboardTrafficStats>
getDashboardSharingStats(now: Date, range: AdminStatsDateRange): Promise<AdminDashboardSharingStats>
getDashboardRankingStats(now: Date, range: AdminStatsDateRange): Promise<AdminDashboardRankingStats>
}
export interface AdminStatsDateRange {
from: Date
to: Date
}
+10 -3
View File
@@ -563,18 +563,25 @@ describe('downloadShareObject', () => {
expect.objectContaining({
orgId: 'o-1',
userId: 'viewer-9',
actorType: 'user',
action: 'share_download',
targetId: 's-1',
targetName: 'file.bin',
metadata: { anonymous: false },
metadata: expect.objectContaining({ anonymous: false, source: 'landing_share', status: 'issued', bytes: 1024 }),
}),
)
})
it('attributes anonymous downloads to the creator', async () => {
it('records anonymous downloads without attributing them to the creator', async () => {
const { deps, record } = makeDeps()
await downloadShareObject(deps, { ...baseParams, viewerId: null })
expect(record).toHaveBeenCalledWith(expect.objectContaining({ userId: 'creator-1', metadata: { anonymous: true } }))
expect(record).toHaveBeenCalledWith(
expect.objectContaining({
userId: null,
actorType: 'anonymous',
metadata: expect.objectContaining({ anonymous: true, creatorId: 'creator-1', source: 'landing_share' }),
}),
)
})
it('still succeeds when the activity record throws', async () => {
+64 -10
View File
@@ -173,7 +173,31 @@ export async function viewShare(deps: ShareDeps, params: ViewShareParams): Promi
// View-dedup increment: non-creators whose view cookie isn't yet 'seen'. The
// handler sets the cookie when setViewCookie is true.
const setViewCookie = !isCreator && viewCookie !== 'seen'
if (setViewCookie) await deps.share.incrementViews(share.id)
if (setViewCookie) {
await deps.share.incrementViews(share.id)
try {
await deps.activity.record({
orgId: share.orgId,
userId: viewerId,
actorType: viewerId ? 'user' : 'anonymous',
action: 'share_view',
targetType: 'share',
targetId: share.id,
targetName: matter.name,
metadata: {
shareId: share.id,
matterId: matter.id,
creatorId: share.creatorId,
kind: share.kind,
requiresPassword: !!share.passwordHash,
matterType: matter.type,
bytes: matter.size ?? 0,
},
})
} catch (error) {
console.error('[shares] recordActivity failed:', error)
}
}
const dto = await composeShareView(deps, { share, matter, recipients }, { viewerId, accessCookie, now })
return { ok: true, setViewCookie, dto }
@@ -181,7 +205,7 @@ export async function viewShare(deps: ShareDeps, params: ViewShareParams): Promi
// ─── POST /:token/sessions — verify password → access-cookie decision ────────
export type VerifySharePasswordParams = { token: string; password: string; now?: Date }
export type VerifySharePasswordParams = { token: string; password: string; viewerId?: string | null; now?: Date }
export type VerifySharePasswordOutcome = { ok: true; setAccessCookieExpiry: Date } | { ok: false; error: AppError }
@@ -189,12 +213,12 @@ export async function verifySharePassword(
deps: ShareDeps,
params: VerifySharePasswordParams,
): Promise<VerifySharePasswordOutcome> {
const { token, password, now = new Date() } = params
const { token, password, viewerId = null, now = new Date() } = params
const resolved = await deps.share.resolveByToken(token)
if (resolved.status !== 'ok') return { ok: false, error: notFound('Share not found or revoked') }
const { share } = resolved
const { share, matter } = resolved
if (share.kind !== 'landing') return { ok: false, error: notFound('Share not found or revoked') }
if (!share.passwordHash || !verifyPasswordHash(share.passwordHash, password))
@@ -206,6 +230,26 @@ export async function verifySharePassword(
? new Date(Math.min(share.expiresAt.getTime(), now.getTime() + oneDayMs))
: new Date(now.getTime() + oneDayMs)
try {
await deps.activity.record({
orgId: share.orgId,
userId: viewerId,
actorType: viewerId ? 'user' : 'anonymous',
action: 'share_password_passed',
targetType: 'share',
targetId: share.id,
targetName: matter.name,
metadata: {
shareId: share.id,
matterId: matter.id,
creatorId: share.creatorId,
kind: share.kind,
},
})
} catch (error) {
console.error('[shares] recordActivity failed:', error)
}
return { ok: true, setAccessCookieExpiry }
}
@@ -364,19 +408,29 @@ export async function downloadShareObject(
throw e
}
// Record download audit event. Use the authenticated viewer if available;
// fall back to the share creator as the org-attributed actor for anonymous
// downloads. The presigned URL is never stored in metadata.
const actorId = viewerId ?? share.creatorId
// Record the real actor. Anonymous downloads belong to the share target, not
// to the share creator as a fake user action. The presigned URL is never stored.
try {
await deps.activity.record({
orgId: share.orgId,
userId: actorId,
userId: viewerId,
actorType: viewerId ? 'user' : 'anonymous',
action: 'share_download',
targetType: 'share',
targetId: share.id,
targetName: targetMatter.name,
metadata: { anonymous: !viewerId },
metadata: {
anonymous: !viewerId,
status: 'issued',
source: 'landing_share',
shareId: share.id,
matterId: targetMatter.id,
rootMatterId: matter.id,
creatorId: share.creatorId,
storageId: targetMatter.storageId,
bytes,
kind: share.kind,
},
})
} catch (error) {
console.error('[shares] recordActivity failed:', error)
+103
View File
@@ -145,3 +145,106 @@ export interface AdminDetailedStats {
}
}
}
export interface AdminStatsRange {
generatedAt: string
from: string
to: string
}
export interface AdminStatsDelta {
value: number
previousValue: number
changePercent: number
}
export interface AdminDashboardOverviewStats extends AdminStatsRange {
totals: {
users: number
newUsers: AdminStatsDelta
activeUsers: AdminStatsDelta
storageUsedBytes: number
storageQuotaBytes: number
trafficBytes: AdminStatsDelta
uploadBytes: AdminStatsDelta
downloadBytes: AdminStatsDelta
activeShares: number
shareViews: AdminStatsDelta
shareDownloads: AdminStatsDelta
}
trends: Array<{
date: string
newUsers: number
activeUsers: number
storageUsedBytes: number
uploadBytes: number
downloadBytes: number
}>
}
export interface AdminDashboardGrowthStats extends AdminStatsRange {
summary: {
totalUsers: number
newUsers: AdminStatsDelta
activeUsers: AdminStatsDelta
verifiedUsers: number
bannedUsers: number
silentUsers: number
}
userScaleTrend: Array<{ date: string; newUsers: number; totalUsers: number }>
activeUserTrend: Array<{ date: string; dau: number; wau: number; mau: number }>
userStatus: Array<{ name: string; value: number; percent: number }>
registrationSources: Array<{ name: string; value: number; percent: number }>
}
export interface AdminDashboardStorageStats extends AdminStatsRange {
summary: {
storageUsedBytes: number
quotaBytes: number
fileCount: number
newFiles: AdminStatsDelta
newBytes: AdminStatsDelta
coldFileBytes: number
}
storageTrend: Array<{ date: string; usedBytes: number; newBytes: number; newFiles: number }>
typeBreakdown: Array<{ type: string; bytes: number; files: number; percent: number }>
sizeBreakdown: Array<{ name: string; bytes: number; files: number; percent: number }>
ageBreakdown: Array<{ name: string; bytes: number; files: number; percent: number }>
}
export interface AdminDashboardTrafficStats extends AdminStatsRange {
summary: {
totalBytes: AdminStatsDelta
requestCount: AdminStatsDelta
issuedDownloads: number
blockedDownloads: number
issueRate: number
peakDailyBytes: number
}
trafficTrend: Array<{ date: string; uploadBytes: number; downloadBytes: number; requests: number }>
sourceBreakdown: Array<{ name: string; bytes: number; requests: number; percent: number }>
issueStatus: Array<{ status: string; count: number; percent: number }>
bandwidthTrend: Array<{ date: string; bytes: number }>
}
export interface AdminDashboardSharingStats extends AdminStatsRange {
summary: {
activeShares: number
createdShares: AdminStatsDelta
views: AdminStatsDelta
downloads: AdminStatsDelta
saves: AdminStatsDelta
downloadConversionRate: number
}
funnel: Array<{ name: string; value: number; percent: number }>
trend: Array<{ date: string; views: number; downloads: number; saves: number }>
typeBreakdown: Array<{ name: string; value: number; percent: number }>
sourceBreakdown: Array<{ name: string; value: number; percent: number }>
topShares: Array<AdminTopShare & { viewPercent: number; downloadPercent: number }>
}
export interface AdminDashboardRankingStats extends AdminStatsRange {
topShares: Array<AdminTopShare & { viewPercent: number; downloadPercent: number }>
topSpaces: AdminUsageBySpace[]
storageByType: AdminStorageByType[]
}
+14 -2
View File
@@ -213,10 +213,18 @@ export type {
AdminBackgroundJobFailure,
AdminCoreStats,
AdminCountByStatus,
AdminDashboardGrowthStats,
AdminDashboardOverviewStats,
AdminDashboardRankingStats,
AdminDashboardSharingStats,
AdminDashboardStorageStats,
AdminDashboardTrafficStats,
AdminDetailedStats,
AdminDownloaderHealth,
AdminDownloadFailureReason,
AdminStatsDelta,
AdminStatsPoint,
AdminStatsRange,
AdminStorageByType,
AdminTopShare,
AdminUsageBySpace,
@@ -532,10 +540,14 @@ export interface Announcement {
updatedAt: string
}
export type ActivityActorType = 'user' | 'anonymous' | 'system' | 'downloader'
export interface ActivityEvent {
id: string
orgId: string
userId: string
userId: string | null
actorType: ActivityActorType
actorRef: string | null
action: string
targetType: string
targetId: string | null
@@ -543,7 +555,7 @@ export interface ActivityEvent {
metadata: string | null
createdAt: string
user: {
id: string
id: string | null
name: string
image: string | null
}
+10 -1
View File
@@ -64,12 +64,13 @@ function AdminAuditActivityItem({ event }: { event: AdminAuditEvent }) {
? Object.entries(metadata).filter(([key, value]) => !['status', 'result', 'from', 'to'].includes(key) && value)
: []
const targetName = event.targetName || event.targetId || event.targetType
const actorLabel = event.user.name || formatActor(event)
return (
<div className="flex items-start gap-3 py-3">
<Avatar className="h-8 w-8 flex-shrink-0">
{event.user.image && <AvatarImage src={event.user.image} alt={event.user.name} />}
<AvatarFallback className="text-xs">{getInitials(event.user.name || event.userId)}</AvatarFallback>
<AvatarFallback className="text-xs">{getInitials(actorLabel)}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
@@ -108,6 +109,14 @@ function AdminAuditActivityItem({ event }: { event: AdminAuditEvent }) {
)
}
function formatActor(event: AdminAuditEvent): string {
if (event.userId) return event.userId
if (event.actorType === 'anonymous') return 'Anonymous'
if (event.actorType === 'system') return event.actorRef ? `System:${event.actorRef}` : 'System'
if (event.actorType === 'downloader') return event.actorRef ? `Downloader:${event.actorRef}` : 'Downloader'
return 'Unknown'
}
function parseActivityMetadata(metadata: string | null): Record<string, string> | null {
if (!metadata) return null
+158
View File
@@ -0,0 +1,158 @@
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
import * as React from 'react'
import { type DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'
import { Button, buttonVariants } from '@/components/ui/button'
import { cn } from '@/lib/utils'
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = 'label',
buttonVariant = 'ghost',
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>['variant']
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
'group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) => date.toLocaleString('default', { month: 'short' }),
...formatters,
}}
classNames={{
root: cn('w-fit', defaultClassNames.root),
months: cn('relative flex flex-col gap-4 md:flex-row', defaultClassNames.months),
month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
nav: cn('absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1', defaultClassNames.nav),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
defaultClassNames.button_next,
),
month_caption: cn(
'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)',
defaultClassNames.month_caption,
),
dropdowns: cn(
'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium',
defaultClassNames.dropdowns,
),
dropdown_root: cn(
'relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50',
defaultClassNames.dropdown_root,
),
dropdown: cn('absolute inset-0 bg-popover opacity-0', defaultClassNames.dropdown),
caption_label: cn(
'font-medium select-none',
captionLayout === 'label'
? 'text-sm'
: 'flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground',
defaultClassNames.caption_label,
),
month_grid: cn('w-full border-collapse', defaultClassNames.month_grid),
weekdays: cn('flex', defaultClassNames.weekdays),
weekday: cn(
'flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none',
defaultClassNames.weekday,
),
week: cn('mt-2 flex w-full', defaultClassNames.week),
week_number_header: cn('w-(--cell-size) select-none', defaultClassNames.week_number_header),
week_number: cn('text-[0.8rem] text-muted-foreground select-none', defaultClassNames.week_number),
day: cn(
'group/day relative aspect-square h-full w-full p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-md',
props.showWeekNumber
? '[&:nth-child(2)[data-selected=true]_button]:rounded-l-md'
: '[&:first-child[data-selected=true]_button]:rounded-l-md',
defaultClassNames.day,
),
range_start: cn('rounded-l-md bg-accent', defaultClassNames.range_start),
range_middle: cn('rounded-none', defaultClassNames.range_middle),
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
today: cn(
'rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none',
defaultClassNames.today,
),
outside: cn('text-muted-foreground aria-selected:text-muted-foreground', defaultClassNames.outside),
disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled),
hidden: cn('invisible', defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === 'left') {
return <ChevronLeftIcon className={cn('size-4', className)} {...props} />
}
if (orientation === 'right') {
return <ChevronRightIcon className={cn('size-4', className)} {...props} />
}
return <ChevronDownIcon className={cn('size-4', className)} {...props} />
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">{children}</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({ className, day, modifiers, ...props }: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
'flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70',
defaultClassNames.day,
className,
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }
+3
View File
@@ -1193,7 +1193,10 @@
"activity.action.trash_empty": "emptied trash",
"activity.action.share_create": "created share for",
"activity.action.share_revoke": "revoked share",
"activity.action.share_view": "viewed share",
"activity.action.share_password_passed": "passed share password for",
"activity.action.share_download": "downloaded via share",
"activity.action.object_download": "downloaded",
"activity.action.team_invite_link_create": "created invite link for",
"activity.action.team_member_join": "joined",
"activity.action.team_member_remove": "removed member from",
+3
View File
@@ -1193,7 +1193,10 @@
"activity.action.trash_empty": "清空了回收站",
"activity.action.share_create": "创建了分享链接",
"activity.action.share_revoke": "撤销了分享",
"activity.action.share_view": "访问了分享",
"activity.action.share_password_passed": "通过了分享密码验证",
"activity.action.share_download": "通过分享下载了",
"activity.action.object_download": "下载了",
"activity.action.team_invite_link_create": "创建了团队邀请链接",
"activity.action.team_member_join": "加入了",
"activity.action.team_member_remove": "移除了成员",
+42
View File
@@ -40,6 +40,12 @@ import {
enableIhostFeature,
generateInviteCodes,
getAdminCoreStats,
getAdminDashboardGrowthStats,
getAdminDashboardOverviewStats,
getAdminDashboardRankingStats,
getAdminDashboardSharingStats,
getAdminDashboardStorageStats,
getAdminDashboardTrafficStats,
getAdminDetailedStats,
getAnnouncement,
getBackgroundJob,
@@ -3934,6 +3940,42 @@ describe('api', () => {
await expect(getAdminDetailedStats()).rejects.toMatchObject({ status: 402 })
})
const dashboardPayload = {
generatedAt: '2026-07-09T00:00:00.000Z',
from: '2026-07-01T00:00:00.000Z',
to: '2026-07-09T00:00:00.000Z',
}
const range = { from: '2026-07-01T00:00:00.000Z', to: '2026-07-09T00:00:00.000Z' }
const dashboardEndpoints = [
{ name: 'getAdminDashboardOverviewStats', path: '/api/admin/stats/overview', fn: getAdminDashboardOverviewStats },
{ name: 'getAdminDashboardGrowthStats', path: '/api/admin/stats/growth', fn: getAdminDashboardGrowthStats },
{ name: 'getAdminDashboardStorageStats', path: '/api/admin/stats/storage', fn: getAdminDashboardStorageStats },
{ name: 'getAdminDashboardTrafficStats', path: '/api/admin/stats/traffic', fn: getAdminDashboardTrafficStats },
{ name: 'getAdminDashboardSharingStats', path: '/api/admin/stats/sharing', fn: getAdminDashboardSharingStats },
{ name: 'getAdminDashboardRankingStats', path: '/api/admin/stats/ranking', fn: getAdminDashboardRankingStats },
] as const
for (const endpoint of dashboardEndpoints) {
it(`${endpoint.name} fetches dashboard stats with from/to`, async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(dashboardPayload))
const result = await endpoint.fn(range)
expect(result).toEqual(dashboardPayload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain(endpoint.path)
expect(decodeURIComponent(url)).toContain(`from=${range.from}`)
expect(decodeURIComponent(url)).toContain(`to=${range.to}`)
expect(init.method).toBe('GET')
})
it(`${endpoint.name} throws ApiError on failure`, async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Feature not available' }, false, 402))
await expect(endpoint.fn(range)).rejects.toMatchObject({ status: 402 })
})
}
})
describe('listAdminAuditLogs', () => {
+42
View File
@@ -26,6 +26,12 @@ import type {
ActivityEvent,
AdminAuditEvent,
AdminCoreStats,
AdminDashboardGrowthStats,
AdminDashboardOverviewStats,
AdminDashboardRankingStats,
AdminDashboardSharingStats,
AdminDashboardStorageStats,
AdminDashboardTrafficStats,
AdminDetailedStats,
Announcement,
AuthProvider,
@@ -438,6 +444,42 @@ export function getAdminDetailedStats(periodDays = 30) {
return unwrap<AdminDetailedStats>(adminStatsApi.details.$get({ query: { periodDays: String(periodDays) } }))
}
export interface AdminStatsRangeFilter {
from?: string
to?: string
}
function statsRangeQuery(filter: AdminStatsRangeFilter = {}): Record<string, string> {
const query: Record<string, string> = {}
if (filter.from) query.from = filter.from
if (filter.to) query.to = filter.to
return query
}
export function getAdminDashboardOverviewStats(filter: AdminStatsRangeFilter = {}) {
return unwrap<AdminDashboardOverviewStats>(adminStatsApi.overview.$get({ query: statsRangeQuery(filter) }))
}
export function getAdminDashboardGrowthStats(filter: AdminStatsRangeFilter = {}) {
return unwrap<AdminDashboardGrowthStats>(adminStatsApi.growth.$get({ query: statsRangeQuery(filter) }))
}
export function getAdminDashboardStorageStats(filter: AdminStatsRangeFilter = {}) {
return unwrap<AdminDashboardStorageStats>(adminStatsApi.storage.$get({ query: statsRangeQuery(filter) }))
}
export function getAdminDashboardTrafficStats(filter: AdminStatsRangeFilter = {}) {
return unwrap<AdminDashboardTrafficStats>(adminStatsApi.traffic.$get({ query: statsRangeQuery(filter) }))
}
export function getAdminDashboardSharingStats(filter: AdminStatsRangeFilter = {}) {
return unwrap<AdminDashboardSharingStats>(adminStatsApi.sharing.$get({ query: statsRangeQuery(filter) }))
}
export function getAdminDashboardRankingStats(filter: AdminStatsRangeFilter = {}) {
return unwrap<AdminDashboardRankingStats>(adminStatsApi.ranking.$get({ query: statsRangeQuery(filter) }))
}
// Admin Storages API
export function listStorages() {
@@ -76,6 +76,8 @@ function auditEvent(overrides: Partial<AdminAuditEvent> = {}): AdminAuditEvent {
orgId: 'org-1',
orgName: 'Personal',
userId: 'user-1',
actorType: 'user',
actorRef: null,
action: 'upload',
targetType: 'file',
targetId: 'file-1',
+11 -2
View File
@@ -70,15 +70,16 @@ function metaDetail(event: AdminAuditEvent, t: (key: string, opts?: Record<strin
function AuditRow({ event }: { event: AdminAuditEvent }) {
const { t } = useTranslation()
const createdAt = new Date(event.createdAt as unknown as string | number)
const actorLabel = event.user.name || formatActor(event)
return (
<div className="flex items-start gap-3 py-3">
<Avatar className="h-8 w-8 flex-shrink-0">
{event.user.image && <AvatarImage src={event.user.image} alt={event.user.name} />}
<AvatarFallback className="text-xs">{userInitials(event.user.name || '?')}</AvatarFallback>
<AvatarFallback className="text-xs">{userInitials(actorLabel)}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="text-sm">
<span className="font-medium">{event.user.name || event.userId}</span>{' '}
<span className="font-medium">{actorLabel}</span>{' '}
<span className="text-muted-foreground">{metaDetail(event, t)}</span>
</p>
<p className="text-xs text-muted-foreground">
@@ -90,6 +91,14 @@ function AuditRow({ event }: { event: AdminAuditEvent }) {
)
}
function formatActor(event: AdminAuditEvent): string {
if (event.userId) return event.userId
if (event.actorType === 'anonymous') return 'Anonymous'
if (event.actorType === 'system') return event.actorRef ? `System:${event.actorRef}` : 'System'
if (event.actorType === 'downloader') return event.actorRef ? `Downloader:${event.actorRef}` : 'Downloader'
return 'Unknown'
}
function AuditLogsPage() {
const { t } = useTranslation()
const { hasFeature, isLoading: entitlementLoading } = useEntitlement()
+48 -35
View File
@@ -1,10 +1,17 @@
import type { AdminCoreStats } from '@shared/types'
import type { AdminDashboardOverviewStats } from '@shared/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, render, screen } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useEntitlement } from '@/hooks/useEntitlement'
import { getAdminCoreStats, getAdminDetailedStats } from '@/lib/api'
import {
getAdminDashboardGrowthStats,
getAdminDashboardOverviewStats,
getAdminDashboardRankingStats,
getAdminDashboardSharingStats,
getAdminDashboardStorageStats,
getAdminDashboardTrafficStats,
} from '@/lib/api'
import { OverviewPage } from './index'
vi.mock('@tanstack/react-router', () => ({
@@ -16,18 +23,6 @@ vi.mock('@tanstack/react-router', () => ({
),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, values?: Record<string, string | number>) => {
if (!values) return key
return Object.entries(values).reduce(
(message, [name, value]) => message.replace(`{{${name}}}`, String(value)),
key,
)
},
}),
}))
vi.mock('@/components/UpgradeHint', () => ({
UpgradeHint: ({ title }: { title: string }) => <div data-testid="upgrade-hint">{title}</div>,
}))
@@ -37,25 +32,41 @@ vi.mock('@/hooks/useEntitlement', () => ({
}))
vi.mock('@/lib/api', () => ({
getAdminCoreStats: vi.fn(),
getAdminDetailedStats: vi.fn(),
getAdminDashboardOverviewStats: vi.fn(),
getAdminDashboardGrowthStats: vi.fn(),
getAdminDashboardStorageStats: vi.fn(),
getAdminDashboardTrafficStats: vi.fn(),
getAdminDashboardSharingStats: vi.fn(),
getAdminDashboardRankingStats: vi.fn(),
}))
const coreStats: AdminCoreStats = {
const overviewStats: AdminDashboardOverviewStats = {
generatedAt: '2026-07-09T00:00:00.000Z',
users: { total: 42, admins: 2, activeLast30Days: 18, newLast7Days: 5 },
spaces: { total: 10, personal: 8, team: 2, newLast30Days: 1 },
storage: {
usedBytes: 1024,
quotaBytes: 4096,
quotaUtilization: 25,
capacityBytes: 8192,
backendCount: 1,
activeBackendCount: 1,
from: '2026-07-01T00:00:00.000Z',
to: '2026-07-09T00:00:00.000Z',
totals: {
users: 42,
newUsers: { value: 5, previousValue: 3, changePercent: 66.7 },
activeUsers: { value: 18, previousValue: 12, changePercent: 50 },
storageUsedBytes: 1024,
storageQuotaBytes: 4096,
trafficBytes: { value: 512, previousValue: 256, changePercent: 100 },
uploadBytes: { value: 128, previousValue: 64, changePercent: 100 },
downloadBytes: { value: 384, previousValue: 192, changePercent: 100 },
activeShares: 4,
shareViews: { value: 120, previousValue: 60, changePercent: 100 },
shareDownloads: { value: 30, previousValue: 15, changePercent: 100 },
},
traffic: { usedBytes: 512, quotaBytes: 2048, utilization: 25, period: '2026-07' },
sharing: { totalShares: 7, activeShares: 4, views: 120, downloads: 30 },
operations: { pendingInvitations: 0, failedBackgroundJobs: 0, offlineDownloaders: 0, runningDownloadTasks: 1 },
trends: [
{
date: '2026-07-09',
newUsers: 5,
activeUsers: 18,
storageUsedBytes: 1024,
uploadBytes: 128,
downloadBytes: 384,
},
],
}
function renderOverviewPage() {
@@ -79,7 +90,7 @@ afterEach(() => {
})
describe('Admin overview dashboard', () => {
it('renders core stats without calling detailed stats when analytics is locked', async () => {
it('renders overview stats without querying Pro sections when analytics is locked', async () => {
vi.mocked(useEntitlement).mockReturnValue({
bound: false,
active: false,
@@ -90,14 +101,16 @@ describe('Admin overview dashboard', () => {
isLoading: false,
isError: false,
})
vi.mocked(getAdminCoreStats).mockResolvedValue(coreStats)
vi.mocked(getAdminDashboardOverviewStats).mockResolvedValue(overviewStats)
renderOverviewPage()
expect(await screen.findByText('42')).toBeTruthy()
expect(screen.getByTestId('upgrade-hint').textContent).toBe('admin.overview.analyticsLockedTitle')
expect(screen.queryByText('admin.overview.pendingTitle')).toBeNull()
expect(screen.queryByText('admin.overview.actionsTitle')).toBeNull()
expect(getAdminDetailedStats).not.toHaveBeenCalled()
expect(screen.getByText('用户与增长')).toBeTruthy()
expect(getAdminDashboardGrowthStats).not.toHaveBeenCalled()
expect(getAdminDashboardStorageStats).not.toHaveBeenCalled()
expect(getAdminDashboardTrafficStats).not.toHaveBeenCalled()
expect(getAdminDashboardSharingStats).not.toHaveBeenCalled()
expect(getAdminDashboardRankingStats).not.toHaveBeenCalled()
})
})
File diff suppressed because it is too large Load Diff
@@ -162,6 +162,8 @@ function auditEvent(overrides: Partial<AdminAuditEvent> = {}): AdminAuditEvent {
orgId: 'route-org-1',
orgName: 'Acme Team',
userId: 'user-1',
actorType: 'user',
actorRef: null,
action: 'upload',
targetType: 'file',
targetId: 'file-1',
@@ -150,6 +150,8 @@ function auditEvent(overrides: Partial<AdminAuditEvent> = {}): AdminAuditEvent {
orgId: 'org-1',
orgName: 'Personal',
userId: 'route-user-1',
actorType: 'user',
actorRef: null,
action: 'upload',
targetType: 'file',
targetId: 'file-1',
@@ -59,17 +59,17 @@ function ActivityItem({ event }: { event: ActivityEvent }) {
}
const createdAt = new Date(event.createdAt as unknown as string | number)
const actorLabel = event.user.name || formatActor(event)
return (
<div className="flex items-start gap-3 py-3">
<Avatar className="h-8 w-8 flex-shrink-0">
{event.user.image && <AvatarImage src={event.user.image} alt={event.user.name} />}
<AvatarFallback className="text-xs">{userInitials(event.user.name || '?')}</AvatarFallback>
<AvatarFallback className="text-xs">{userInitials(actorLabel)}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="text-sm">
<span className="font-medium">{event.user.name || event.userId}</span>{' '}
<span className="text-muted-foreground">{detail}</span>
<span className="font-medium">{actorLabel}</span> <span className="text-muted-foreground">{detail}</span>
</p>
<p className="text-xs text-muted-foreground">{relativeTime(createdAt)}</p>
</div>
@@ -77,6 +77,14 @@ function ActivityItem({ event }: { event: ActivityEvent }) {
)
}
function formatActor(event: ActivityEvent): string {
if (event.userId) return event.userId
if (event.actorType === 'anonymous') return 'Anonymous'
if (event.actorType === 'system') return event.actorRef ? `System:${event.actorRef}` : 'System'
if (event.actorType === 'downloader') return event.actorRef ? `Downloader:${event.actorRef}` : 'Downloader'
return 'Unknown'
}
function TeamActivityPage() {
const { t } = useTranslation()
const { teamId } = Route.useParams()