diff --git a/migrations/0054_flippant_lila_cheney.sql b/migrations/0054_lively_nuke.sql similarity index 57% rename from migrations/0054_flippant_lila_cheney.sql rename to migrations/0054_lively_nuke.sql index 2d2e7106..ef7f4a1b 100644 --- a/migrations/0054_flippant_lila_cheney.sql +++ b/migrations/0054_lively_nuke.sql @@ -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 diff --git a/migrations/meta/0054_snapshot.json b/migrations/meta/0054_snapshot.json index 4676bc94..dd6c0f80 100644 --- a/migrations/meta/0054_snapshot.json +++ b/migrations/meta/0054_snapshot.json @@ -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": { diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 8f87153e..a9c70ccd 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -376,8 +376,8 @@ { "idx": 54, "version": "6", - "when": 1783616186203, - "tag": "0054_flippant_lila_cheney", + "when": 1783647878596, + "tag": "0054_lively_nuke", "breakpoints": true } ] diff --git a/server/adapters/repos/activity.ts b/server/adapters/repos/activity.ts index 5b1d33c3..ef0e8fe5 100644 --- a/server/adapters/repos/activity.ts +++ b/server/adapters/repos/activity.ts @@ -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, diff --git a/server/adapters/repos/admin-stats.ts b/server/adapters/repos/admin-stats.ts index aa9f790a..36142218 100644 --- a/server/adapters/repos/admin-stats.ts +++ b/server/adapters/repos/admin-stats.ts @@ -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 { - 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 { - 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 { 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, -): Promise { - const rows = await db - .select({ value: sql`COUNT(DISTINCT ${column})` }) - .from(activityEvents) - .where(where) - return toNumber(rows[0]?.value) -} - -async function listSpaces(db: Database, last30Days: Date): Promise { - 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 { - const rows = await db - .select({ - backendCount: count(), - activeBackendCount: sql`SUM(CASE WHEN ${storages.status} = 'active' THEN 1 ELSE 0 END)`, - capacityBytes: sql`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 { - const rows = await db - .select({ - totalShares: count(), - activeShares: sql`SUM(CASE WHEN ${shares.status} = 'active' THEN 1 ELSE 0 END)`, - views: sql`COALESCE(SUM(${shares.views}), 0)`, - downloads: sql`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 { - 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>() - 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() - 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 { +async function getStorageByType(db: Database, range?: AdminStatsDateRange): Promise { 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 { - 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 { - const nowSec = unixSeconds(now) - const rows = await db - .select({ - expiredShares: sql`SUM(CASE WHEN ${shares.expiresAt} IS NOT NULL AND ${shares.expiresAt} <= ${nowSec} THEN 1 ELSE 0 END)`, - revokedShares: sql`SUM(CASE WHEN ${shares.status} = 'revoked' THEN 1 ELSE 0 END)`, - downloadLimitHitShares: sql`SUM(CASE WHEN ${shares.downloadLimit} IS NOT NULL AND ${shares.downloads} >= ${shares.downloadLimit} THEN 1 ELSE 0 END)`, - views: sql`COALESCE(SUM(${shares.views}), 0)`, - downloads: sql`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 -> { - const rows = await db - .select({ - total: count(), - completed: sql`SUM(CASE WHEN ${downloadTasks.status} = 'completed' THEN 1 ELSE 0 END)`, - failed: sql`SUM(CASE WHEN ${downloadTasks.status} = 'failed' THEN 1 ELSE 0 END)`, - running: sql`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 { - 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 { - const rows = await db - .select({ - reason: sql`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 { - const rows = await db - .select({ - downloaderId: downloaders.id, - name: downloaders.name, - status: downloaders.status, - lastHeartbeatAt: downloaders.lastHeartbeatAt, - tasks: sql`COUNT(${downloadTasks.id})`, - failedTasks: sql`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> { - const rows = await db - .select({ - total: count(), - failed: sql`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 { - 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 { - 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 { - 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> { +async function getStorageUsedByDay(db: Database, range: AdminStatsDateRange): Promise> { 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 { +async function getTopSharesByActivity(db: Database, range: AdminStatsDateRange): Promise { 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 { - const buckets = new Map() - 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 diff --git a/server/db/schema.ts b/server/db/schema.ts index 3d5b603d..0bf80bd7 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -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', { diff --git a/server/http/admin-stats.integration.test.ts b/server/http/admin-stats.integration.test.ts index 3a6b8226..7d4e0c0d 100644 --- a/server/http/admin-stats.integration.test.ts +++ b/server/http/admin-stats.integration.test.ts @@ -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 }> } } 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) diff --git a/server/http/admin-stats.ts b/server/http/admin-stats.ts index 5ca250b0..92227d1d 100644 --- a/server/http/admin-stats.ts +++ b/server/http/admin-stats.ts @@ -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() - .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), ) diff --git a/server/test/setup.ts b/server/test/setup.ts index 7bd63a58..4ea2aa01 100644 --- a/server/test/setup.ts +++ b/server/test/setup.ts @@ -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, diff --git a/server/usecases/admin-stats.ts b/server/usecases/admin-stats.ts index 018690fd..cb9ad2e6 100644 --- a/server/usecases/admin-stats.ts +++ b/server/usecases/admin-stats.ts @@ -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 - licenseBinding: LicenseBindingRepo -} - -export async function getAdminCoreStats(deps: AdminStatsDeps, now = new Date()): Promise { - 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 { - 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((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) diff --git a/server/usecases/ports/admin-stats.ts b/server/usecases/ports/admin-stats.ts index f3047bf8..ccfaf81d 100644 --- a/server/usecases/ports/admin-stats.ts +++ b/server/usecases/ports/admin-stats.ts @@ -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 - sharing: AdminCoreStats['sharing'] - operations: AdminCoreStats['operations'] -} - -export interface AdminDetailedStatsBase { - trends: AdminStatsPoint[] - storageByType: AdminStorageByType[] - topShares: AdminTopShare[] - sharing: AdminDetailedStats['sharing'] - remoteDownloads: Omit & { - failureReasons: AdminDownloadFailureReason[] - byDownloader: AdminDownloaderHealth[] - } - backgroundJobs: { - total: number - failed: number - byStatus: AdminCountByStatus[] - failures: AdminBackgroundJobFailure[] - } - cloudTrafficReports: AdminDetailedStats['reliability']['cloudTrafficReports'] -} - export interface AdminStatsRepo { - getCoreStatsBase(now: Date): Promise - getDetailedStatsBase(now: Date, periodDays: number): Promise getDashboardOverviewStats(now: Date, range: AdminStatsDateRange): Promise getDashboardGrowthStats(now: Date, range: AdminStatsDateRange): Promise getDashboardStorageStats(now: Date, range: AdminStatsDateRange): Promise diff --git a/shared/types/admin-stats.ts b/shared/types/admin-stats.ts index 079fd108..f7b87f4a 100644 --- a/shared/types/admin-stats.ts +++ b/shared/types/admin-stats.ts @@ -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 diff --git a/shared/types/index.ts b/shared/types/index.ts index dbc19290..ff02ab15 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -210,20 +210,13 @@ export interface PaginatedResponse { } export type { - AdminBackgroundJobFailure, - AdminCoreStats, - AdminCountByStatus, AdminDashboardGrowthStats, AdminDashboardOverviewStats, AdminDashboardRankingStats, AdminDashboardSharingStats, AdminDashboardStorageStats, AdminDashboardTrafficStats, - AdminDetailedStats, - AdminDownloaderHealth, - AdminDownloadFailureReason, AdminStatsDelta, - AdminStatsPoint, AdminStatsRange, AdminStorageByType, AdminTopShare, diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index e110d03b..ac0a16d9 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -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', diff --git a/src/lib/api.ts b/src/lib/api.ts index 18d6bd73..2b0d7724 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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(adminStatsApi.core.$get()) -} - -export function getAdminDetailedStats(periodDays = 30) { - return unwrap(adminStatsApi.details.$get({ query: { periodDays: String(periodDays) } })) -} - export interface AdminStatsRangeFilter { from?: string to?: string