diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9606e172..bfbc4a48 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -360,8 +360,8 @@ "admin.nav.settings": "Settings", "admin.nav.cloudStore": "Storage Plans", "admin.nav.about": "About", - "admin.overview.title": "Admin overview", - "admin.overview.subtitle": "Key operational signals for users, storage, quota, and invitations.", + "admin.overview.title": "Operations dashboard", + "admin.overview.subtitle": "Monitor instance growth, usage, capacity, sharing, and operating quality from one view.", "admin.overview.view": "View", "admin.overview.noQuota": "No quota data", "admin.overview.noStorages": "No storage backends configured yet.", @@ -389,9 +389,9 @@ "admin.overview.storageBackendCount": "{{active}} / {{total}} active backends", "admin.overview.shareActivity": "Share views", "admin.overview.shareActivityDetail": "{{downloads}} downloads", - "admin.overview.analyticsTitle": "Detailed analytics", + "admin.overview.analyticsTitle": "Operational analytics", "admin.overview.analyticsBadge": "Pro+", - "admin.overview.analyticsDescription": "Detailed trends, top spaces, file mix, remote downloads, and reliability data.", + "admin.overview.analyticsDescription": "Pro-level trends, composition, top lists, and reliability diagnostics.", "admin.overview.period.7": "7 days", "admin.overview.period.30": "30 days", "admin.overview.period.90": "90 days", @@ -400,8 +400,8 @@ "admin.overview.analyticsLockedAction": "Open licensing", "admin.overview.analyticsErrorTitle": "Detailed analytics unavailable", "admin.overview.analyticsErrorDescription": "The Pro analytics endpoint could not be loaded.", - "admin.overview.charts.activityTitle": "Activity trends", - "admin.overview.charts.activityDescription": "Daily active users, share views, remote tasks, and failed jobs.", + "admin.overview.charts.activityTitle": "Operating trend", + "admin.overview.charts.activityDescription": "Daily movement across users, sharing, remote tasks, and failures.", "admin.overview.charts.activeUsers": "Active users", "admin.overview.charts.shareViews": "Share views", "admin.overview.charts.remoteTasks": "Remote tasks", @@ -482,35 +482,47 @@ "admin.downloaders.deleteTitle": "Delete downloader", "admin.downloaders.deleteConfirm": "Delete downloader {{name}}? Active assigned tasks will be returned to the queue.", "admin.downloaders.deleteSuccess": "Downloader deleted.", - "admin.overview.metrics.usersHint": "Total registered accounts", - "admin.overview.metrics.storage": "Storage", - "admin.overview.metrics.storageHint": "Active / configured backends", - "admin.overview.metrics.quota": "Quota usage", - "admin.overview.metrics.invites": "Pending invites", - "admin.overview.metrics.invitesHint": "Open site registration invitations", - "admin.overview.capacityTitle": "Capacity", - "admin.overview.capacityDescription": "Storage and organization quota usage from existing records.", - "admin.overview.orgQuota": "Organization quotas", - "admin.overview.pendingTitle": "Pending work", - "admin.overview.pendingDescription": "Operational items that may need attention.", - "admin.overview.noPendingWork": "No pending operational items.", - "admin.overview.pending.storageTitle": "Configure a storage backend", - "admin.overview.pending.storageDescription": "Uploads need at least one active storage backend.", - "admin.overview.pending.quotaTitle": "Quota usage is high", - "admin.overview.pending.quotaDescription": "Organization quota usage is at {{percent}}%.", - "admin.overview.pending.trafficTitle": "Traffic usage is high", - "admin.overview.pending.trafficDescription": "Traffic quota usage is at {{percent}}%.", - "admin.overview.pending.invitesTitle": "{{count}} pending invitation(s)", - "admin.overview.pending.invitesDescription": "Review pending site invitations and resend or revoke as needed.", - "admin.overview.pending.downloadersTitle": "{{count}} offline downloader(s)", - "admin.overview.pending.downloadersDescription": "Check downloader connectivity before assigning more remote download work.", - "admin.overview.pending.jobsTitle": "{{count}} failed background job(s)", - "admin.overview.pending.jobsDescription": "Review failed background jobs and retry or clean up affected work.", - "admin.overview.actionsTitle": "Quick actions", - "admin.overview.actions.manageUsers": "Manage users", - "admin.overview.actions.configureStorage": "Configure storage", - "admin.overview.actions.downloaders": "Downloaders", - "admin.overview.actions.siteSettings": "Site settings", + "admin.overview.metrics.activeUsers": "30d active", + "admin.overview.metrics.activeUsersDetail": "{{percent}} of total users", + "admin.overview.metrics.storage": "Storage used", + "admin.overview.metrics.storageDetail": "{{percent}} of quota · {{quota}}", + "admin.overview.capacityTitle": "Capacity posture", + "admin.overview.capacityDescription": "Quota pressure, backend availability, and operating risk from core records.", + "admin.overview.storageBackendHealth": "Backend health", + "admin.overview.totalStorageCapacity": "{{capacity}} total configured capacity", + "admin.overview.riskSignals": "Risk signals", + "admin.overview.riskSignalsDetail": "{{failed}} failed jobs · {{offline}} offline downloaders", + "admin.overview.mixTitle": "Operating mix", + "admin.overview.mixDescription": "Core ratios that show adoption, collaboration, and sharing depth.", + "admin.overview.mix.activeUsers": "Active user ratio", + "admin.overview.mix.activeUsersDetail": "{{active}} / {{total}} users active in 30d", + "admin.overview.mix.teamSpaces": "Team space ratio", + "admin.overview.mix.teamSpacesDetail": "{{team}} / {{total}} spaces are team spaces", + "admin.overview.mix.activeShares": "Active share ratio", + "admin.overview.mix.activeSharesDetail": "{{active}} / {{total}} shares are active", + "admin.overview.mix.shareDownloads": "Share download rate", + "admin.overview.mix.shareDownloadsDetail": "{{downloads}} downloads from {{views}} views", + "admin.overview.periodKpi.signups": "Signups", + "admin.overview.periodKpi.periodDays": "Last {{days}} days", + "admin.overview.periodKpi.shareViews": "Share views", + "admin.overview.periodKpi.shareDownloads": "{{downloads}} downloads", + "admin.overview.periodKpi.conversion": "Download conversion", + "admin.overview.periodKpi.conversionDetail": "Downloads per share view", + "admin.overview.periodKpi.remoteSuccess": "Remote success", + "admin.overview.periodKpi.jobFailures": "Job failures", + "admin.overview.periodKpi.topSpacePressure": "Top space pressure", + "admin.overview.periodKpi.topSpacePressureDetail": "Highest quota utilization", + "admin.overview.charts.signups": "Signups", + "admin.overview.charts.shareDownloads": "Share downloads", + "admin.overview.table.bytes": "Bytes", + "admin.overview.sharePerformanceTitle": "Share performance", + "admin.overview.sharePerformanceDescription": "Conversion and guardrails for public share links.", + "admin.overview.conversionRate": "Conversion rate", + "admin.overview.conversionRateDetail": "Downloads divided by share views", + "admin.overview.expiredShares": "Expired shares", + "admin.overview.revokedShares": "{{count}} revoked shares", + "admin.overview.downloadLimitHitShares": "Limit reached", + "admin.overview.downloadLimitHitSharesDetail": "Shares that hit their download cap", "admin.users.title": "Users", "admin.users.placeholder": "User management will be implemented here.", "admin.users.searchPlaceholder": "Search by name, username, or email", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 5395f217..c5e3f0d3 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -360,8 +360,8 @@ "admin.nav.settings": "设置", "admin.nav.cloudStore": "存储套餐", "admin.nav.about": "关于", - "admin.overview.title": "管理后台概览", - "admin.overview.subtitle": "集中查看用户、存储、配额和邀请的关键运营状态。", + "admin.overview.title": "运营仪表盘", + "admin.overview.subtitle": "集中监控实例增长、用量、容量、分享和运营质量。", "admin.overview.view": "查看", "admin.overview.noQuota": "暂无配额数据", "admin.overview.noStorages": "还没有配置存储后端。", @@ -389,9 +389,9 @@ "admin.overview.storageBackendCount": "{{active}} / {{total}} 个后端可用", "admin.overview.shareActivity": "分享访问", "admin.overview.shareActivityDetail": "{{downloads}} 次下载", - "admin.overview.analyticsTitle": "详细统计", + "admin.overview.analyticsTitle": "运营分析", "admin.overview.analyticsBadge": "Pro+", - "admin.overview.analyticsDescription": "查看趋势、重点空间、文件类型分布、远程下载和可靠性数据。", + "admin.overview.analyticsDescription": "Pro 级趋势、构成、排行和可靠性诊断。", "admin.overview.period.7": "7 天", "admin.overview.period.30": "30 天", "admin.overview.period.90": "90 天", @@ -400,8 +400,8 @@ "admin.overview.analyticsLockedAction": "打开授权", "admin.overview.analyticsErrorTitle": "详细统计不可用", "admin.overview.analyticsErrorDescription": "无法加载 Pro 统计接口。", - "admin.overview.charts.activityTitle": "活动趋势", - "admin.overview.charts.activityDescription": "按天查看活跃用户、分享访问、远程下载任务和失败任务。", + "admin.overview.charts.activityTitle": "运营趋势", + "admin.overview.charts.activityDescription": "按天查看用户、分享、远程任务和失败量的变化。", "admin.overview.charts.activeUsers": "活跃用户", "admin.overview.charts.shareViews": "分享访问", "admin.overview.charts.remoteTasks": "远程任务", @@ -482,35 +482,47 @@ "admin.downloaders.deleteTitle": "删除下载器", "admin.downloaders.deleteConfirm": "确定删除下载器 {{name}} 吗?未完成的已分配任务会回到队列。", "admin.downloaders.deleteSuccess": "下载器已删除。", - "admin.overview.metrics.usersHint": "已注册账号总数", - "admin.overview.metrics.storage": "存储", - "admin.overview.metrics.storageHint": "启用 / 已配置后端", - "admin.overview.metrics.quota": "配额用量", - "admin.overview.metrics.invites": "待处理邀请", - "admin.overview.metrics.invitesHint": "仍可使用的站点注册邀请", - "admin.overview.capacityTitle": "容量", - "admin.overview.capacityDescription": "基于现有记录汇总存储和组织配额用量。", - "admin.overview.orgQuota": "组织配额", - "admin.overview.pendingTitle": "待处理事项", - "admin.overview.pendingDescription": "可能需要管理员关注的运营事项。", - "admin.overview.noPendingWork": "暂无待处理事项。", - "admin.overview.pending.storageTitle": "配置存储后端", - "admin.overview.pending.storageDescription": "上传文件至少需要一个可用的存储后端。", - "admin.overview.pending.quotaTitle": "配额用量偏高", - "admin.overview.pending.quotaDescription": "组织配额用量已达到 {{percent}}%。", - "admin.overview.pending.trafficTitle": "流量用量偏高", - "admin.overview.pending.trafficDescription": "流量配额用量已达到 {{percent}}%。", - "admin.overview.pending.invitesTitle": "{{count}} 个待处理邀请", - "admin.overview.pending.invitesDescription": "检查待处理的站点邀请,并按需重发或撤销。", - "admin.overview.pending.downloadersTitle": "{{count}} 个下载器离线", - "admin.overview.pending.downloadersDescription": "继续分配远程下载任务前,请检查下载器连接状态。", - "admin.overview.pending.jobsTitle": "{{count}} 个后台任务失败", - "admin.overview.pending.jobsDescription": "检查失败的后台任务,并按需重试或清理受影响的工作。", - "admin.overview.actionsTitle": "快捷操作", - "admin.overview.actions.manageUsers": "管理用户", - "admin.overview.actions.configureStorage": "配置存储", - "admin.overview.actions.downloaders": "下载器", - "admin.overview.actions.siteSettings": "站点设置", + "admin.overview.metrics.activeUsers": "30 天活跃", + "admin.overview.metrics.activeUsersDetail": "占总用户 {{percent}}", + "admin.overview.metrics.storage": "存储用量", + "admin.overview.metrics.storageDetail": "占配额 {{percent}} · {{quota}}", + "admin.overview.capacityTitle": "容量态势", + "admin.overview.capacityDescription": "基于核心记录查看配额压力、后端可用性和运营风险。", + "admin.overview.storageBackendHealth": "后端健康", + "admin.overview.totalStorageCapacity": "已配置总容量 {{capacity}}", + "admin.overview.riskSignals": "风险信号", + "admin.overview.riskSignalsDetail": "{{failed}} 个失败任务 · {{offline}} 个下载器离线", + "admin.overview.mixTitle": "运营结构", + "admin.overview.mixDescription": "展示活跃、协作和分享深度的核心比例。", + "admin.overview.mix.activeUsers": "活跃用户占比", + "admin.overview.mix.activeUsersDetail": "{{active}} / {{total}} 用户 30 天活跃", + "admin.overview.mix.teamSpaces": "团队空间占比", + "admin.overview.mix.teamSpacesDetail": "{{team}} / {{total}} 空间为团队空间", + "admin.overview.mix.activeShares": "活跃分享占比", + "admin.overview.mix.activeSharesDetail": "{{active}} / {{total}} 分享仍在生效", + "admin.overview.mix.shareDownloads": "分享下载率", + "admin.overview.mix.shareDownloadsDetail": "{{views}} 次访问带来 {{downloads}} 次下载", + "admin.overview.periodKpi.signups": "新增用户", + "admin.overview.periodKpi.periodDays": "最近 {{days}} 天", + "admin.overview.periodKpi.shareViews": "分享访问", + "admin.overview.periodKpi.shareDownloads": "{{downloads}} 次下载", + "admin.overview.periodKpi.conversion": "下载转化", + "admin.overview.periodKpi.conversionDetail": "下载次数 / 分享访问", + "admin.overview.periodKpi.remoteSuccess": "远程成功率", + "admin.overview.periodKpi.jobFailures": "失败任务", + "admin.overview.periodKpi.topSpacePressure": "最高空间压力", + "admin.overview.periodKpi.topSpacePressureDetail": "最高配额使用率", + "admin.overview.charts.signups": "新增用户", + "admin.overview.charts.shareDownloads": "分享下载", + "admin.overview.table.bytes": "容量", + "admin.overview.sharePerformanceTitle": "分享表现", + "admin.overview.sharePerformanceDescription": "公开分享链接的转化和限制状态。", + "admin.overview.conversionRate": "转化率", + "admin.overview.conversionRateDetail": "下载次数 / 分享访问", + "admin.overview.expiredShares": "已过期分享", + "admin.overview.revokedShares": "{{count}} 个已撤销分享", + "admin.overview.downloadLimitHitShares": "触达下载上限", + "admin.overview.downloadLimitHitSharesDetail": "已达到下载次数上限的分享", "admin.users.title": "用户", "admin.users.placeholder": "用户管理将在此实现。", "admin.users.searchPlaceholder": "按名称、用户名或邮箱搜索", diff --git a/src/routes/_authenticated/admin/index.test.tsx b/src/routes/_authenticated/admin/index.test.tsx index b457ada0..a249a28d 100644 --- a/src/routes/_authenticated/admin/index.test.tsx +++ b/src/routes/_authenticated/admin/index.test.tsx @@ -96,6 +96,8 @@ describe('Admin overview dashboard', () => { expect(await screen.findByText('42')).toBeTruthy() expect(screen.getByTestId('upgrade-hint').textContent).toBe('admin.overview.analyticsLockedTitle') + expect(screen.queryByText('admin.overview.pendingTitle')).toBeNull() + expect(screen.queryByText('admin.overview.actionsTitle')).toBeNull() expect(getAdminDetailedStats).not.toHaveBeenCalled() }) }) diff --git a/src/routes/_authenticated/admin/index.tsx b/src/routes/_authenticated/admin/index.tsx index 682a1fb9..875efd75 100644 --- a/src/routes/_authenticated/admin/index.tsx +++ b/src/routes/_authenticated/admin/index.tsx @@ -1,22 +1,18 @@ import type { AdminCoreStats, AdminDetailedStats } from '@shared/types' import { useQuery } from '@tanstack/react-query' -import { createFileRoute, Link } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' import type { LucideIcon } from 'lucide-react' import { Activity, AlertTriangle, - ArrowRight, CheckCircle2, Cloud, Database, Gauge, HardDrive, Link2, - MailPlus, RefreshCw, - Settings, ShieldCheck, - UserPlus, Users, Workflow, } from 'lucide-react' @@ -29,6 +25,7 @@ import { BarChart, CartesianGrid, Cell, + Legend, Tooltip as RechartsTooltip, ResponsiveContainer, XAxis, @@ -50,11 +47,10 @@ export const Route = createFileRoute('/_authenticated/admin/')({ }) type PeriodDays = 7 | 30 | 90 -type AdminHref = '/admin/users' | '/admin/storages' | '/admin/settings' | '/admin/downloaders' | '/admin/licensing' const PERIODS: PeriodDays[] = [7, 30, 90] -const CHART_COLORS = ['#2563eb', '#16a34a', '#f97316', '#7c3aed', '#0891b2', '#dc2626', '#ca8a04', '#db2777'] -const SKELETON_METRICS = ['users', 'spaces', 'storage', 'traffic', 'shares', 'operations'] as const +const CHART_COLORS = ['#2563eb', '#0891b2', '#16a34a', '#f97316', '#7c3aed', '#dc2626', '#ca8a04', '#db2777'] +const SKELETON_METRICS = ['users', 'active', 'spaces', 'storage', 'traffic', 'shares'] as const export function OverviewPage() { const { t } = useTranslation() @@ -79,7 +75,7 @@ export function OverviewPage() { if (coreQuery.isError || !coreQuery.data) { return ( - + {t('admin.overview.errorTitle')} {t('admin.overview.errorDescription')} @@ -89,10 +85,9 @@ export function OverviewPage() { } const core = coreQuery.data - const alerts = buildAlerts(core, t) return ( -
+
@@ -109,73 +104,11 @@ export function OverviewPage() {
- + -
- - - {t('admin.overview.coreCapacityTitle')} - {t('admin.overview.coreCapacityDescription')} - - - - - - - - - -
- - - {t('admin.overview.pendingTitle')} - {t('admin.overview.pendingDescription')} - - - {alerts.length === 0 && ( -
- {t('admin.overview.noPendingWork')} -
- )} - {alerts.map((alert) => ( - - ))} -
-
- - - - {t('admin.overview.actionsTitle')} - - - - - - - - -
+
+ +
@@ -215,7 +148,7 @@ export function OverviewPage() { )} {!entitlementLoading && hasAnalytics && detailsQuery.isLoading && } {!entitlementLoading && hasAnalytics && detailsQuery.isError && ( - + {t('admin.overview.analyticsErrorTitle')} {t('admin.overview.analyticsErrorDescription')} @@ -228,7 +161,7 @@ export function OverviewPage() { ) } -function CoreDashboard({ core }: { core: AdminCoreStats }) { +function CoreMetricGrid({ core }: { core: AdminCoreStats }) { const { t } = useTranslation() const metrics = [ { @@ -240,8 +173,14 @@ function CoreDashboard({ core }: { core: AdminCoreStats }) { admins: formatNumber(core.users.admins), }), icon: Users, - color: 'text-blue-600', - background: 'bg-blue-500/10', + }, + { + title: t('admin.overview.metrics.activeUsers'), + value: formatNumber(core.users.activeLast30Days), + detail: t('admin.overview.metrics.activeUsersDetail', { + percent: formatPercentRatio(core.users.activeLast30Days, core.users.total), + }), + icon: Activity, }, { title: t('admin.overview.metrics.spaces'), @@ -252,27 +191,24 @@ function CoreDashboard({ core }: { core: AdminCoreStats }) { newSpaces: formatNumber(core.spaces.newLast30Days), }), icon: Workflow, - color: 'text-emerald-600', - background: 'bg-emerald-500/10', }, { title: t('admin.overview.metrics.storage'), - value: formatPercent(core.storage.quotaUtilization), - detail: formatQuota(core.storage.usedBytes, core.storage.quotaBytes), + value: formatSize(core.storage.usedBytes), + detail: t('admin.overview.metrics.storageDetail', { + percent: formatPercent(core.storage.quotaUtilization), + quota: formatQuota(core.storage.usedBytes, core.storage.quotaBytes), + }), icon: Database, - color: 'text-orange-600', - background: 'bg-orange-500/10', }, { title: t('admin.overview.metrics.traffic'), - value: formatPercent(core.traffic.utilization), + value: formatSize(core.traffic.usedBytes), detail: t('admin.overview.metrics.trafficDetail', { usage: formatQuota(core.traffic.usedBytes, core.traffic.quotaBytes), period: core.traffic.period, }), - icon: Gauge, - color: 'text-violet-600', - background: 'bg-violet-500/10', + icon: Cloud, }, { title: t('admin.overview.metrics.shares'), @@ -282,53 +218,221 @@ function CoreDashboard({ core }: { core: AdminCoreStats }) { views: formatNumber(core.sharing.views), }), icon: Link2, - color: 'text-cyan-600', - background: 'bg-cyan-500/10', - }, - { - title: t('admin.overview.metrics.operations'), - value: formatNumber(core.operations.failedBackgroundJobs + core.operations.offlineDownloaders), - detail: t('admin.overview.metrics.operationsDetail', { - running: formatNumber(core.operations.runningDownloadTasks), - invites: formatNumber(core.operations.pendingInvitations), - }), - icon: AlertTriangle, - color: 'text-rose-600', - background: 'bg-rose-500/10', }, ] return ( -
+
{metrics.map((metric) => ( - - - {metric.title} -
- -
-
- -
{metric.value}
-

- {metric.detail} -

-
-
+ ))}
) } +function CoreMetricCard({ + title, + value, + detail, + icon: Icon, +}: { + title: string + value: string + detail: string + icon: LucideIcon +}) { + return ( + + + {title} + + + +
{value}
+

+ {detail} +

+
+
+ ) +} + +function CapacityPostureCard({ core }: { core: AdminCoreStats }) { + const { t } = useTranslation() + + return ( + + + {t('admin.overview.capacityTitle')} + {t('admin.overview.capacityDescription')} + + + + + + + + + ) +} + +function OperatingMixCard({ core }: { core: AdminCoreStats }) { + const { t } = useTranslation() + const shareDownloadRate = formatPercentRatio(core.sharing.downloads, core.sharing.views) + + return ( + + + {t('admin.overview.mixTitle')} + {t('admin.overview.mixDescription')} + + + + + + + + + ) +} + function DetailedDashboard({ stats }: { stats: AdminDetailedStats }) { return ( -
+
+ - - - - - +
+ + +
+
+ + +
+
+ + +
+
+ ) +} + +function PeriodSummaryGrid({ stats }: { stats: AdminDetailedStats }) { + const { t } = useTranslation() + const totals = summarizeTrends(stats) + const topSpacePressure = stats.usageBySpace.reduce((max, space) => Math.max(max, space.utilization), 0) + const cards = [ + { + label: t('admin.overview.periodKpi.signups'), + value: formatNumber(totals.signups), + detail: t('admin.overview.periodKpi.periodDays', { days: stats.periodDays }), + }, + { + label: t('admin.overview.periodKpi.shareViews'), + value: formatNumber(totals.shareViews), + detail: t('admin.overview.periodKpi.shareDownloads', { downloads: formatNumber(totals.shareDownloads) }), + }, + { + label: t('admin.overview.periodKpi.conversion'), + value: formatPercent(stats.sharing.conversionRate), + detail: t('admin.overview.periodKpi.conversionDetail'), + }, + { + label: t('admin.overview.periodKpi.remoteSuccess'), + value: formatPercent(stats.remoteDownloads.successRate), + detail: t('admin.overview.remoteCompleted', { + completed: formatNumber(stats.remoteDownloads.completed), + total: formatNumber(stats.remoteDownloads.total), + }), + }, + { + label: t('admin.overview.periodKpi.jobFailures'), + value: formatNumber(stats.reliability.backgroundJobs.failed), + detail: t('admin.overview.jobFailures', { + failed: formatNumber(stats.reliability.backgroundJobs.failed), + total: formatNumber(stats.reliability.backgroundJobs.total), + }), + }, + { + label: t('admin.overview.periodKpi.topSpacePressure'), + value: formatPercent(topSpacePressure), + detail: t('admin.overview.periodKpi.topSpacePressureDetail'), + }, + ] + + return ( +
+ {cards.map((card) => ( + + ))} +
+ ) +} + +function AnalyticsKpiCard({ label, value, detail }: { label: string; value: string; detail: string }) { + return ( +
+

{label}

+

{value}

+

+ {detail} +

) } @@ -344,10 +448,11 @@ function ActivityTrendCard({ stats }: { stats: AdminDetailedStats }) { point.remoteTasks > 0 || point.failedJobs > 0, ) + const data = stats.trends.map((point) => ({ ...point, label: formatChartDate(point.date) })) return ( - - + + {t('admin.overview.charts.activityTitle')} {t('admin.overview.charts.activityDescription')} @@ -355,12 +460,13 @@ function ActivityTrendCard({ stats }: { stats: AdminDetailedStats }) { {!hasData ? ( ) : ( -
+
- ({ ...point, label: formatChartDate(point.date) }))}> + - + + String(label)} /> + + @@ -412,8 +536,8 @@ function StorageByTypeCard({ stats }: { stats: AdminDetailedStats }) { const data = stats.storageByType.map((item) => ({ ...item, label: formatStorageType(item.type) })) return ( - - + + {t('admin.overview.charts.storageByTypeTitle')} {t('admin.overview.charts.storageByTypeDescription')} @@ -421,9 +545,9 @@ function StorageByTypeCard({ stats }: { stats: AdminDetailedStats }) { {data.length === 0 ? ( ) : ( -
+
- + formatSize(Number(value))} /> - + {data.map((item, index) => ( ))} @@ -452,8 +576,8 @@ function UsageBySpaceCard({ stats }: { stats: AdminDetailedStats }) { const { t } = useTranslation() return ( - - + + {t('admin.overview.usageBySpaceTitle')} {t('admin.overview.usageBySpaceDescription')} @@ -480,12 +604,88 @@ function UsageBySpaceCard({ stats }: { stats: AdminDetailedStats }) { ) } +function SharePerformanceCard({ stats }: { stats: AdminDetailedStats }) { + const { t } = useTranslation() + + return ( + + + {t('admin.overview.sharePerformanceTitle')} + {t('admin.overview.sharePerformanceDescription')} + + + + + + + + ) +} + +function TopSharesCard({ stats }: { stats: AdminDetailedStats }) { + const { t } = useTranslation() + + return ( + + + {t('admin.overview.topSharesTitle')} + {t('admin.overview.topSharesDescription')} + + + {stats.topShares.length === 0 ? ( + + ) : ( + + + + {t('admin.overview.table.share')} + {t('admin.overview.table.creator')} + {t('admin.overview.table.views')} + {t('admin.overview.table.downloads')} + + + + {stats.topShares.map((share) => ( + + + {share.name} + + + {share.creatorName} + + {formatNumber(share.views)} + {formatNumber(share.downloads)} + + ))} + +
+ )} +
+
+ ) +} + function RemoteDownloadsCard({ stats }: { stats: AdminDetailedStats }) { const { t } = useTranslation() return ( - - + + {t('admin.overview.remoteDownloadsTitle')} {t('admin.overview.remoteDownloadsDescription')} @@ -529,56 +729,13 @@ function RemoteDownloadsCard({ stats }: { stats: AdminDetailedStats }) { ) } -function TopSharesCard({ stats }: { stats: AdminDetailedStats }) { - const { t } = useTranslation() - - return ( - - - {t('admin.overview.topSharesTitle')} - {t('admin.overview.topSharesDescription')} - - - {stats.topShares.length === 0 ? ( - - ) : ( - - - - {t('admin.overview.table.share')} - {t('admin.overview.table.creator')} - {t('admin.overview.table.views')} - {t('admin.overview.table.downloads')} - - - - {stats.topShares.map((share) => ( - - - {share.name} - - - {share.creatorName} - - {formatNumber(share.views)} - {formatNumber(share.downloads)} - - ))} - -
- )} -
-
- ) -} - function ReliabilityCard({ stats }: { stats: AdminDetailedStats }) { const { t } = useTranslation() const license = stats.reliability.license return ( - - + + {t('admin.overview.reliabilityTitle')} {t('admin.overview.reliabilityDescription')} @@ -603,7 +760,7 @@ function ReliabilityCard({ stats }: { stats: AdminDetailedStats }) {
+
+
+

{label}

+

+ {detail} +

+
+ {value} +
+ +
+ ) +} + function StatusList({ title, items }: { title: string; items: Array<{ status: string; count: number }> }) { const { t } = useTranslation() @@ -661,48 +845,21 @@ function StatusList({ title, items }: { title: string; items: Array<{ status: st ) } -function UsageBar({ label, value, description }: { label: string; value: number; description: string }) { +function CapacityMeter({ label, value, description }: { label: string; value: number; description: string }) { return (
{label} - - {description} - + {formatPercent(value)}
-

{formatPercent(value)}

+

+ {description} +

) } -function PendingItem({ title, description, href }: { title: string; description: string; href: AdminHref }) { - return ( - -
- -
-
-

{title}

-

{description}

-
- - - ) -} - -function ActionLink({ href, icon: Icon, label }: { href: AdminHref; icon: LucideIcon; label: string }) { - return ( - - ) -} - function SummaryTile({ icon: Icon, label, @@ -715,7 +872,7 @@ function SummaryTile({ detail: string }) { return ( -
+
{label} @@ -730,19 +887,19 @@ function SummaryTile({ function OverviewSkeleton() { return ( -
+
- +
-
+
{SKELETON_METRICS.map((metric) => ( ))}
-
- - +
+ +
) @@ -750,10 +907,17 @@ function OverviewSkeleton() { function DetailedSkeleton() { return ( -
- - +
+
+ {SKELETON_METRICS.map((metric) => ( + + ))} +
+
+ + +
) } @@ -764,55 +928,17 @@ function EmptyState({ label }: { label: string }) { ) } -function buildAlerts(core: AdminCoreStats, t: ReturnType['t']) { - const alerts: Array<{ title: string; description: string; href: AdminHref }> = [] - - if (core.storage.backendCount === 0) { - alerts.push({ - title: t('admin.overview.pending.storageTitle'), - description: t('admin.overview.pending.storageDescription'), - href: '/admin/storages', - }) - } - if (core.storage.quotaBytes > 0 && core.storage.quotaUtilization >= 80) { - alerts.push({ - title: t('admin.overview.pending.quotaTitle'), - description: t('admin.overview.pending.quotaDescription', { - percent: formatPercent(core.storage.quotaUtilization), - }), - href: '/admin/users', - }) - } - if (core.traffic.quotaBytes > 0 && core.traffic.utilization >= 80) { - alerts.push({ - title: t('admin.overview.pending.trafficTitle'), - description: t('admin.overview.pending.trafficDescription', { percent: formatPercent(core.traffic.utilization) }), - href: '/admin/users', - }) - } - if (core.operations.pendingInvitations > 0) { - alerts.push({ - title: t('admin.overview.pending.invitesTitle', { count: core.operations.pendingInvitations }), - description: t('admin.overview.pending.invitesDescription'), - href: '/admin/users', - }) - } - if (core.operations.offlineDownloaders > 0) { - alerts.push({ - title: t('admin.overview.pending.downloadersTitle', { count: core.operations.offlineDownloaders }), - description: t('admin.overview.pending.downloadersDescription'), - href: '/admin/downloaders', - }) - } - if (core.operations.failedBackgroundJobs > 0) { - alerts.push({ - title: t('admin.overview.pending.jobsTitle', { count: core.operations.failedBackgroundJobs }), - description: t('admin.overview.pending.jobsDescription'), - href: '/admin/settings', - }) - } - - return alerts +function summarizeTrends(stats: AdminDetailedStats) { + return stats.trends.reduce( + (totals, point) => ({ + signups: totals.signups + point.signups, + shareViews: totals.shareViews + point.shareViews, + shareDownloads: totals.shareDownloads + point.shareDownloads, + remoteTasks: totals.remoteTasks + point.remoteTasks, + failedJobs: totals.failedJobs + point.failedJobs, + }), + { signups: 0, shareViews: 0, shareDownloads: 0, remoteTasks: 0, failedJobs: 0 }, + ) } function formatNumber(value: number): string { @@ -823,6 +949,15 @@ function formatPercent(value: number): string { return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(value)}%` } +function formatPercentRatio(numerator: number, denominator: number): string { + return formatPercent(percentage(numerator, denominator)) +} + +function percentage(numerator: number, denominator: number): number { + if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator <= 0) return 0 + return (numerator / denominator) * 100 +} + function formatQuota(used: number, total: number): string { if (total <= 0) return formatSize(used) return `${formatSize(used)} / ${formatSize(total)}`