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