mirror of
https://github.com/saltbo/zpan.git
synced 2026-10-30 18:31:36 +08:00
feat(admin): implement operations dashboard stats
This commit is contained in:
+1068
-21
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Generated
+57
@@ -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: {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "移除了成员",
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user