refactor(admin): focus overview on operations dashboard

This commit is contained in:
saltbo
2026-07-09 23:34:03 -04:00
committed by Jasper Van
parent 554c231536
commit 983a5540bd
4 changed files with 515 additions and 354 deletions
+47 -35
View File
@@ -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",
+47 -35
View File
@@ -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": "按名称、用户名或邮箱搜索",
@@ -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()
})
})
+419 -284
View File
@@ -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 (
<Card className="border-destructive/40">
<Card className="border-destructive/40 shadow-none">
<CardHeader>
<CardTitle>{t('admin.overview.errorTitle')}</CardTitle>
<CardDescription>{t('admin.overview.errorDescription')}</CardDescription>
@@ -89,10 +85,9 @@ export function OverviewPage() {
}
const core = coreQuery.data
const alerts = buildAlerts(core, t)
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div className="flex flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
@@ -109,73 +104,11 @@ export function OverviewPage() {
</div>
</div>
<CoreDashboard core={core} />
<CoreMetricGrid core={core} />
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px]">
<Card className="border-border/60">
<CardHeader>
<CardTitle>{t('admin.overview.coreCapacityTitle')}</CardTitle>
<CardDescription>{t('admin.overview.coreCapacityDescription')}</CardDescription>
</CardHeader>
<CardContent className="grid gap-5 lg:grid-cols-2">
<UsageBar
label={t('admin.overview.storageQuota')}
value={core.storage.quotaUtilization}
description={formatQuota(core.storage.usedBytes, core.storage.quotaBytes)}
/>
<UsageBar
label={t('admin.overview.trafficQuota')}
value={core.traffic.utilization}
description={formatQuota(core.traffic.usedBytes, core.traffic.quotaBytes)}
/>
<SummaryTile
icon={HardDrive}
label={t('admin.overview.storageCapacity')}
value={formatSize(core.storage.capacityBytes)}
detail={t('admin.overview.storageBackendCount', {
active: core.storage.activeBackendCount,
total: core.storage.backendCount,
})}
/>
<SummaryTile
icon={Link2}
label={t('admin.overview.shareActivity')}
value={formatNumber(core.sharing.views)}
detail={t('admin.overview.shareActivityDetail', { downloads: formatNumber(core.sharing.downloads) })}
/>
</CardContent>
</Card>
<div className="flex flex-col gap-4">
<Card className="border-border/60">
<CardHeader>
<CardTitle>{t('admin.overview.pendingTitle')}</CardTitle>
<CardDescription>{t('admin.overview.pendingDescription')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{alerts.length === 0 && (
<div className="rounded-md border border-dashed px-4 py-6 text-sm text-muted-foreground">
{t('admin.overview.noPendingWork')}
</div>
)}
{alerts.map((alert) => (
<PendingItem key={alert.title} {...alert} />
))}
</CardContent>
</Card>
<Card className="border-border/60">
<CardHeader>
<CardTitle>{t('admin.overview.actionsTitle')}</CardTitle>
</CardHeader>
<CardContent className="grid gap-2">
<ActionLink href="/admin/users" icon={UserPlus} label={t('admin.overview.actions.manageUsers')} />
<ActionLink href="/admin/storages" icon={Database} label={t('admin.overview.actions.configureStorage')} />
<ActionLink href="/admin/downloaders" icon={Cloud} label={t('admin.overview.actions.downloaders')} />
<ActionLink href="/admin/settings" icon={Settings} label={t('admin.overview.actions.siteSettings')} />
</CardContent>
</Card>
</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.25fr)_minmax(320px,0.75fr)]">
<CapacityPostureCard core={core} />
<OperatingMixCard core={core} />
</div>
<section className="flex flex-col gap-4">
@@ -215,7 +148,7 @@ export function OverviewPage() {
)}
{!entitlementLoading && hasAnalytics && detailsQuery.isLoading && <DetailedSkeleton />}
{!entitlementLoading && hasAnalytics && detailsQuery.isError && (
<Card className="border-destructive/40">
<Card className="border-destructive/40 shadow-none">
<CardHeader>
<CardTitle>{t('admin.overview.analyticsErrorTitle')}</CardTitle>
<CardDescription>{t('admin.overview.analyticsErrorDescription')}</CardDescription>
@@ -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 (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
{metrics.map((metric) => (
<Card key={metric.title} className="border-border/60">
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{metric.title}</CardTitle>
<div className={`rounded-md p-2 ${metric.background} ${metric.color}`}>
<metric.icon className="h-4 w-4" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold tabular-nums">{metric.value}</div>
<p className="mt-1 line-clamp-2 min-h-8 text-xs text-muted-foreground" title={String(metric.detail)}>
{metric.detail}
</p>
</CardContent>
</Card>
<CoreMetricCard key={metric.title} {...metric} />
))}
</div>
)
}
function CoreMetricCard({
title,
value,
detail,
icon: Icon,
}: {
title: string
value: string
detail: string
icon: LucideIcon
}) {
return (
<Card className="border-border/60 shadow-none">
<CardHeader className="flex flex-row items-center justify-between gap-3 space-y-0 p-4 pb-2">
<CardTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{title}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent className="px-4 pb-4">
<div className="text-2xl font-semibold tabular-nums">{value}</div>
<p className="mt-1 line-clamp-2 min-h-8 text-xs text-muted-foreground" title={detail}>
{detail}
</p>
</CardContent>
</Card>
)
}
function CapacityPostureCard({ core }: { core: AdminCoreStats }) {
const { t } = useTranslation()
return (
<Card className="border-border/60 shadow-none">
<CardHeader className="pb-3">
<CardTitle>{t('admin.overview.capacityTitle')}</CardTitle>
<CardDescription>{t('admin.overview.capacityDescription')}</CardDescription>
</CardHeader>
<CardContent className="grid gap-5 lg:grid-cols-2">
<CapacityMeter
label={t('admin.overview.storageQuota')}
value={core.storage.quotaUtilization}
description={formatQuota(core.storage.usedBytes, core.storage.quotaBytes)}
/>
<CapacityMeter
label={t('admin.overview.trafficQuota')}
value={core.traffic.utilization}
description={formatQuota(core.traffic.usedBytes, core.traffic.quotaBytes)}
/>
<SummaryTile
icon={HardDrive}
label={t('admin.overview.storageBackendHealth')}
value={t('admin.overview.storageBackendCount', {
active: core.storage.activeBackendCount,
total: core.storage.backendCount,
})}
detail={t('admin.overview.totalStorageCapacity', { capacity: formatSize(core.storage.capacityBytes) })}
/>
<SummaryTile
icon={Gauge}
label={t('admin.overview.riskSignals')}
value={formatNumber(core.operations.failedBackgroundJobs + core.operations.offlineDownloaders)}
detail={t('admin.overview.riskSignalsDetail', {
failed: formatNumber(core.operations.failedBackgroundJobs),
offline: formatNumber(core.operations.offlineDownloaders),
})}
/>
</CardContent>
</Card>
)
}
function OperatingMixCard({ core }: { core: AdminCoreStats }) {
const { t } = useTranslation()
const shareDownloadRate = formatPercentRatio(core.sharing.downloads, core.sharing.views)
return (
<Card className="border-border/60 shadow-none">
<CardHeader className="pb-3">
<CardTitle>{t('admin.overview.mixTitle')}</CardTitle>
<CardDescription>{t('admin.overview.mixDescription')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<MetricRow
label={t('admin.overview.mix.activeUsers')}
value={formatPercentRatio(core.users.activeLast30Days, core.users.total)}
detail={t('admin.overview.mix.activeUsersDetail', {
active: formatNumber(core.users.activeLast30Days),
total: formatNumber(core.users.total),
})}
percent={percentage(core.users.activeLast30Days, core.users.total)}
/>
<MetricRow
label={t('admin.overview.mix.teamSpaces')}
value={formatPercentRatio(core.spaces.team, core.spaces.total)}
detail={t('admin.overview.mix.teamSpacesDetail', {
team: formatNumber(core.spaces.team),
total: formatNumber(core.spaces.total),
})}
percent={percentage(core.spaces.team, core.spaces.total)}
/>
<MetricRow
label={t('admin.overview.mix.activeShares')}
value={formatPercentRatio(core.sharing.activeShares, core.sharing.totalShares)}
detail={t('admin.overview.mix.activeSharesDetail', {
active: formatNumber(core.sharing.activeShares),
total: formatNumber(core.sharing.totalShares),
})}
percent={percentage(core.sharing.activeShares, core.sharing.totalShares)}
/>
<MetricRow
label={t('admin.overview.mix.shareDownloads')}
value={shareDownloadRate}
detail={t('admin.overview.mix.shareDownloadsDetail', {
downloads: formatNumber(core.sharing.downloads),
views: formatNumber(core.sharing.views),
})}
percent={percentage(core.sharing.downloads, core.sharing.views)}
/>
</CardContent>
</Card>
)
}
function DetailedDashboard({ stats }: { stats: AdminDetailedStats }) {
return (
<div className="grid gap-4 xl:grid-cols-2">
<div className="flex flex-col gap-4">
<PeriodSummaryGrid stats={stats} />
<ActivityTrendCard stats={stats} />
<StorageByTypeCard stats={stats} />
<UsageBySpaceCard stats={stats} />
<RemoteDownloadsCard stats={stats} />
<TopSharesCard stats={stats} />
<ReliabilityCard stats={stats} />
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_420px]">
<StorageByTypeCard stats={stats} />
<UsageBySpaceCard stats={stats} />
</div>
<div className="grid gap-4 xl:grid-cols-[420px_minmax(0,1fr)]">
<SharePerformanceCard stats={stats} />
<TopSharesCard stats={stats} />
</div>
<div className="grid gap-4 xl:grid-cols-2">
<RemoteDownloadsCard stats={stats} />
<ReliabilityCard stats={stats} />
</div>
</div>
)
}
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 (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
{cards.map((card) => (
<AnalyticsKpiCard key={card.label} {...card} />
))}
</div>
)
}
function AnalyticsKpiCard({ label, value, detail }: { label: string; value: string; detail: string }) {
return (
<div className="rounded-md border bg-card px-4 py-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{label}</p>
<p className="mt-2 text-2xl font-semibold tabular-nums">{value}</p>
<p className="mt-1 truncate text-xs text-muted-foreground" title={detail}>
{detail}
</p>
</div>
)
}
@@ -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 (
<Card className="border-border/60 xl:col-span-2">
<CardHeader>
<Card className="border-border/60 shadow-none">
<CardHeader className="pb-3">
<CardTitle>{t('admin.overview.charts.activityTitle')}</CardTitle>
<CardDescription>{t('admin.overview.charts.activityDescription')}</CardDescription>
</CardHeader>
@@ -355,12 +460,13 @@ function ActivityTrendCard({ stats }: { stats: AdminDetailedStats }) {
{!hasData ? (
<EmptyState label={t('admin.overview.emptyChart')} />
) : (
<div className="h-72">
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={stats.trends.map((point) => ({ ...point, label: formatChartDate(point.date) }))}>
<AreaChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="label" tickLine={false} axisLine={false} fontSize={12} minTickGap={18} />
<YAxis tickFormatter={formatCompactNumber} tickLine={false} axisLine={false} width={40} fontSize={12} />
<YAxis tickFormatter={formatCompactNumber} tickLine={false} axisLine={false} width={42} fontSize={12} />
<Legend iconType="circle" wrapperStyle={{ fontSize: 12 }} />
<RechartsTooltip formatter={formatTooltipValue} labelFormatter={(label) => String(label)} />
<Area
type="monotone"
@@ -368,25 +474,43 @@ function ActivityTrendCard({ stats }: { stats: AdminDetailedStats }) {
name={t('admin.overview.charts.activeUsers')}
stroke={CHART_COLORS[0]}
fill={CHART_COLORS[0]}
fillOpacity={0.12}
fillOpacity={0.1}
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="signups"
name={t('admin.overview.charts.signups')}
stroke={CHART_COLORS[1]}
fill={CHART_COLORS[1]}
fillOpacity={0.08}
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="shareViews"
name={t('admin.overview.charts.shareViews')}
stroke={CHART_COLORS[1]}
fill={CHART_COLORS[1]}
fillOpacity={0.1}
stroke={CHART_COLORS[2]}
fill={CHART_COLORS[2]}
fillOpacity={0.08}
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="shareDownloads"
name={t('admin.overview.charts.shareDownloads')}
stroke={CHART_COLORS[3]}
fill={CHART_COLORS[3]}
fillOpacity={0.08}
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="remoteTasks"
name={t('admin.overview.charts.remoteTasks')}
stroke={CHART_COLORS[2]}
fill={CHART_COLORS[2]}
fillOpacity={0.1}
stroke={CHART_COLORS[4]}
fill={CHART_COLORS[4]}
fillOpacity={0.08}
strokeWidth={2}
/>
<Area
@@ -395,7 +519,7 @@ function ActivityTrendCard({ stats }: { stats: AdminDetailedStats }) {
name={t('admin.overview.charts.failedJobs')}
stroke={CHART_COLORS[5]}
fill={CHART_COLORS[5]}
fillOpacity={0.1}
fillOpacity={0.08}
strokeWidth={2}
/>
</AreaChart>
@@ -412,8 +536,8 @@ function StorageByTypeCard({ stats }: { stats: AdminDetailedStats }) {
const data = stats.storageByType.map((item) => ({ ...item, label: formatStorageType(item.type) }))
return (
<Card className="border-border/60">
<CardHeader>
<Card className="border-border/60 shadow-none">
<CardHeader className="pb-3">
<CardTitle>{t('admin.overview.charts.storageByTypeTitle')}</CardTitle>
<CardDescription>{t('admin.overview.charts.storageByTypeDescription')}</CardDescription>
</CardHeader>
@@ -421,9 +545,9 @@ function StorageByTypeCard({ stats }: { stats: AdminDetailedStats }) {
{data.length === 0 ? (
<EmptyState label={t('admin.overview.emptyChart')} />
) : (
<div className="h-72">
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} layout="vertical" margin={{ left: 12, right: 12 }}>
<BarChart data={data} layout="vertical" margin={{ top: 8, right: 16, left: 8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
<XAxis
type="number"
@@ -434,7 +558,7 @@ function StorageByTypeCard({ stats }: { stats: AdminDetailedStats }) {
/>
<YAxis type="category" dataKey="label" width={104} tickLine={false} axisLine={false} fontSize={12} />
<RechartsTooltip formatter={(value) => formatSize(Number(value))} />
<Bar dataKey="bytes" radius={[0, 4, 4, 0]}>
<Bar dataKey="bytes" name={t('admin.overview.table.bytes')} radius={[0, 4, 4, 0]}>
{data.map((item, index) => (
<Cell key={item.type} fill={CHART_COLORS[index % CHART_COLORS.length]} />
))}
@@ -452,8 +576,8 @@ function UsageBySpaceCard({ stats }: { stats: AdminDetailedStats }) {
const { t } = useTranslation()
return (
<Card className="border-border/60">
<CardHeader>
<Card className="border-border/60 shadow-none">
<CardHeader className="pb-3">
<CardTitle>{t('admin.overview.usageBySpaceTitle')}</CardTitle>
<CardDescription>{t('admin.overview.usageBySpaceDescription')}</CardDescription>
</CardHeader>
@@ -480,12 +604,88 @@ function UsageBySpaceCard({ stats }: { stats: AdminDetailedStats }) {
)
}
function SharePerformanceCard({ stats }: { stats: AdminDetailedStats }) {
const { t } = useTranslation()
return (
<Card className="border-border/60 shadow-none">
<CardHeader className="pb-3">
<CardTitle>{t('admin.overview.sharePerformanceTitle')}</CardTitle>
<CardDescription>{t('admin.overview.sharePerformanceDescription')}</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
<SummaryTile
icon={Gauge}
label={t('admin.overview.conversionRate')}
value={formatPercent(stats.sharing.conversionRate)}
detail={t('admin.overview.conversionRateDetail')}
/>
<SummaryTile
icon={AlertTriangle}
label={t('admin.overview.expiredShares')}
value={formatNumber(stats.sharing.expiredShares)}
detail={t('admin.overview.revokedShares', { count: formatNumber(stats.sharing.revokedShares) })}
/>
<SummaryTile
icon={ShieldCheck}
label={t('admin.overview.downloadLimitHitShares')}
value={formatNumber(stats.sharing.downloadLimitHitShares)}
detail={t('admin.overview.downloadLimitHitSharesDetail')}
/>
</CardContent>
</Card>
)
}
function TopSharesCard({ stats }: { stats: AdminDetailedStats }) {
const { t } = useTranslation()
return (
<Card className="border-border/60 shadow-none">
<CardHeader className="pb-3">
<CardTitle>{t('admin.overview.topSharesTitle')}</CardTitle>
<CardDescription>{t('admin.overview.topSharesDescription')}</CardDescription>
</CardHeader>
<CardContent>
{stats.topShares.length === 0 ? (
<EmptyState label={t('admin.overview.emptyTable')} />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('admin.overview.table.share')}</TableHead>
<TableHead>{t('admin.overview.table.creator')}</TableHead>
<TableHead className="text-right">{t('admin.overview.table.views')}</TableHead>
<TableHead className="text-right">{t('admin.overview.table.downloads')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stats.topShares.map((share) => (
<TableRow key={share.id}>
<TableCell className="max-w-56 truncate font-medium" title={share.name}>
{share.name}
</TableCell>
<TableCell className="max-w-40 truncate text-muted-foreground" title={share.creatorName}>
{share.creatorName}
</TableCell>
<TableCell className="text-right tabular-nums">{formatNumber(share.views)}</TableCell>
<TableCell className="text-right tabular-nums">{formatNumber(share.downloads)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)
}
function RemoteDownloadsCard({ stats }: { stats: AdminDetailedStats }) {
const { t } = useTranslation()
return (
<Card className="border-border/60">
<CardHeader>
<Card className="border-border/60 shadow-none">
<CardHeader className="pb-3">
<CardTitle>{t('admin.overview.remoteDownloadsTitle')}</CardTitle>
<CardDescription>{t('admin.overview.remoteDownloadsDescription')}</CardDescription>
</CardHeader>
@@ -529,56 +729,13 @@ function RemoteDownloadsCard({ stats }: { stats: AdminDetailedStats }) {
)
}
function TopSharesCard({ stats }: { stats: AdminDetailedStats }) {
const { t } = useTranslation()
return (
<Card className="border-border/60">
<CardHeader>
<CardTitle>{t('admin.overview.topSharesTitle')}</CardTitle>
<CardDescription>{t('admin.overview.topSharesDescription')}</CardDescription>
</CardHeader>
<CardContent>
{stats.topShares.length === 0 ? (
<EmptyState label={t('admin.overview.emptyTable')} />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('admin.overview.table.share')}</TableHead>
<TableHead>{t('admin.overview.table.creator')}</TableHead>
<TableHead className="text-right">{t('admin.overview.table.views')}</TableHead>
<TableHead className="text-right">{t('admin.overview.table.downloads')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stats.topShares.map((share) => (
<TableRow key={share.id}>
<TableCell className="max-w-44 truncate font-medium" title={share.name}>
{share.name}
</TableCell>
<TableCell className="max-w-32 truncate text-muted-foreground" title={share.creatorName}>
{share.creatorName}
</TableCell>
<TableCell className="text-right tabular-nums">{formatNumber(share.views)}</TableCell>
<TableCell className="text-right tabular-nums">{formatNumber(share.downloads)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)
}
function ReliabilityCard({ stats }: { stats: AdminDetailedStats }) {
const { t } = useTranslation()
const license = stats.reliability.license
return (
<Card className="border-border/60">
<CardHeader>
<Card className="border-border/60 shadow-none">
<CardHeader className="pb-3">
<CardTitle>{t('admin.overview.reliabilityTitle')}</CardTitle>
<CardDescription>{t('admin.overview.reliabilityDescription')}</CardDescription>
</CardHeader>
@@ -603,7 +760,7 @@ function ReliabilityCard({ stats }: { stats: AdminDetailedStats }) {
<StatusList title={t('admin.overview.jobStatusBreakdown')} items={stats.reliability.backgroundJobs.byStatus} />
<div className="grid gap-3 md:grid-cols-2">
<SummaryTile
icon={MailPlus}
icon={Cloud}
label={t('admin.overview.cloudReportPending')}
value={formatNumber(stats.reliability.cloudTrafficReports.pending)}
detail={t('admin.overview.cloudReportPendingDetail')}
@@ -638,6 +795,33 @@ function ReliabilityCard({ stats }: { stats: AdminDetailedStats }) {
)
}
function MetricRow({
label,
value,
detail,
percent,
}: {
label: string
value: string
detail: string
percent: number
}) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3 text-sm">
<div className="min-w-0">
<p className="font-medium">{label}</p>
<p className="truncate text-xs text-muted-foreground" title={detail}>
{detail}
</p>
</div>
<span className="shrink-0 font-semibold tabular-nums">{value}</span>
</div>
<Progress value={clampPercent(percent)} />
</div>
)
}
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 (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium">{label}</span>
<span className="truncate text-muted-foreground" title={description}>
{description}
</span>
<span className="shrink-0 font-semibold tabular-nums">{formatPercent(value)}</span>
</div>
<Progress value={clampPercent(value)} />
<p className="text-xs text-muted-foreground">{formatPercent(value)}</p>
<p className="truncate text-xs text-muted-foreground" title={description}>
{description}
</p>
</div>
)
}
function PendingItem({ title, description, href }: { title: string; description: string; href: AdminHref }) {
return (
<Link to={href} className="flex items-start gap-3 rounded-md border px-4 py-3 transition-colors hover:bg-muted/40">
<div className="rounded-md bg-amber-500/10 p-2 text-amber-600">
<AlertTriangle className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{title}</p>
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
</div>
<ArrowRight className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
</Link>
)
}
function ActionLink({ href, icon: Icon, label }: { href: AdminHref; icon: LucideIcon; label: string }) {
return (
<Button variant="outline" className="justify-start" asChild>
<Link to={href}>
<Icon className="mr-2 h-4 w-4" />
{label}
<ArrowRight className="ml-auto h-4 w-4 text-muted-foreground" />
</Link>
</Button>
)
}
function SummaryTile({
icon: Icon,
label,
@@ -715,7 +872,7 @@ function SummaryTile({
detail: string
}) {
return (
<div className="rounded-md border bg-card px-3 py-3">
<div className="rounded-md border bg-muted/20 px-3 py-3">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<Icon className="h-3.5 w-3.5" />
<span className="truncate">{label}</span>
@@ -730,19 +887,19 @@ function SummaryTile({
function OverviewSkeleton() {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<Skeleton className="h-8 w-56" />
<Skeleton className="h-4 w-80 max-w-full" />
<Skeleton className="h-4 w-96 max-w-full" />
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
{SKELETON_METRICS.map((metric) => (
<Skeleton key={metric} className="h-32" />
))}
</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px]">
<Skeleton className="h-80" />
<Skeleton className="h-80" />
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.25fr)_minmax(320px,0.75fr)]">
<Skeleton className="h-72" />
<Skeleton className="h-72" />
</div>
</div>
)
@@ -750,10 +907,17 @@ function OverviewSkeleton() {
function DetailedSkeleton() {
return (
<div className="grid gap-4 xl:grid-cols-2">
<Skeleton className="h-80 xl:col-span-2" />
<Skeleton className="h-80" />
<div className="flex flex-col gap-4">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
{SKELETON_METRICS.map((metric) => (
<Skeleton key={metric} className="h-24" />
))}
</div>
<Skeleton className="h-80" />
<div className="grid gap-4 xl:grid-cols-2">
<Skeleton className="h-80" />
<Skeleton className="h-80" />
</div>
</div>
)
}
@@ -764,55 +928,17 @@ function EmptyState({ label }: { label: string }) {
)
}
function buildAlerts(core: AdminCoreStats, t: ReturnType<typeof useTranslation>['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)}`