mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-12 18:26:36 +08:00
feat(frontend): 控制台 next-intl 中英双语
接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { TrendChart } from '@/components/data/trend-chart';
|
||||
import {
|
||||
Card,
|
||||
@@ -14,18 +16,23 @@ import { formatPercent, formatTrendHour } from './dashboard-utils';
|
||||
|
||||
export function CapacityTrendChart({
|
||||
points,
|
||||
title = '24 小时容量趋势',
|
||||
description = '按小时聚合 CPU 与内存使用率,判断整体容量是否持续紧张。',
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
points: CapacityTrendPoint[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const t = useTranslations('dashboard.capacityTrend');
|
||||
return (
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-sm font-semibold'>{title}</CardTitle>
|
||||
<CardDescription className='text-xs'>{description}</CardDescription>
|
||||
<CardTitle className='text-sm font-semibold'>
|
||||
{title ?? t('title')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
{description ?? t('description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TrendChart
|
||||
@@ -35,7 +42,7 @@ export function CapacityTrendChart({
|
||||
yAxisValueFormatter={formatPercent}
|
||||
series={[
|
||||
{
|
||||
label: '平均 CPU',
|
||||
label: t('avgCpu'),
|
||||
color: '#0f766e',
|
||||
fillColor: 'rgba(15, 118, 110, 0.15)',
|
||||
variant: 'area',
|
||||
@@ -43,7 +50,7 @@ export function CapacityTrendChart({
|
||||
valueFormatter: formatPercent,
|
||||
},
|
||||
{
|
||||
label: '平均内存',
|
||||
label: t('avgMemory'),
|
||||
color: '#2563eb',
|
||||
values: points.map((point) => point.average_memory_usage_percent),
|
||||
valueFormatter: formatPercent,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { RankChart } from '@/components/data/rank-chart';
|
||||
import {
|
||||
Card,
|
||||
@@ -25,19 +27,20 @@ export function SourceDistributionChart({
|
||||
}: {
|
||||
items: TrafficDistributions['source_countries'];
|
||||
}) {
|
||||
const t = useTranslations('dashboard.distributions');
|
||||
return (
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-sm font-semibold'>来源分布</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
聚合最近 24 小时主要来源国家。
|
||||
</CardDescription>
|
||||
<CardTitle className='text-sm font-semibold'>
|
||||
{t('sourceTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>{t('sourceDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RankChart
|
||||
items={toRankItems(items)}
|
||||
color='#38bdf8'
|
||||
emptyMessage='暂无来源分布数据'
|
||||
emptyMessage={t('sourceEmpty')}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -49,22 +52,23 @@ export function StatusCodeDistributionChart({
|
||||
}: {
|
||||
items: TrafficDistributions['status_codes'];
|
||||
}) {
|
||||
const t = useTranslations('dashboard.distributions');
|
||||
return (
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-sm font-semibold'>状态码分布</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
快速判断成功响应是否仍是主流,以及错误码是否有抬升。
|
||||
</CardDescription>
|
||||
<CardTitle className='text-sm font-semibold'>
|
||||
{t('statusTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>{t('statusDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RankChart
|
||||
items={toRankItems(items).map((item) => ({
|
||||
...item,
|
||||
label: `HTTP ${item.label}`,
|
||||
label: t('httpLabel', { code: item.label }),
|
||||
}))}
|
||||
color='#f59e0b'
|
||||
emptyMessage='暂无状态码分布'
|
||||
emptyMessage={t('statusEmpty')}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -76,19 +80,20 @@ export function TopDomainChart({
|
||||
}: {
|
||||
items: TrafficDistributions['top_domains'];
|
||||
}) {
|
||||
const t = useTranslations('dashboard.distributions');
|
||||
return (
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-sm font-semibold'>Top Domain</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
观察主要流量集中在哪些域名。
|
||||
</CardDescription>
|
||||
<CardTitle className='text-sm font-semibold'>
|
||||
{t('domainTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>{t('domainDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RankChart
|
||||
items={toRankItems(items)}
|
||||
color='#34d399'
|
||||
emptyMessage='暂无域名分布'
|
||||
emptyMessage={t('domainEmpty')}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -33,6 +34,8 @@ import {
|
||||
import { formatCompactNumber, formatPercent } from './dashboard-utils';
|
||||
|
||||
export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
|
||||
const t = useTranslations('dashboard.nodeHealth');
|
||||
const tn = useTranslations('nodes');
|
||||
const sortedNodes = [...nodes].sort((left, right) => {
|
||||
if (right.active_event_count !== left.active_event_count) {
|
||||
return right.active_event_count - left.active_event_count;
|
||||
@@ -54,33 +57,33 @@ export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='flex flex-row items-start justify-between gap-4'>
|
||||
<div>
|
||||
<CardTitle className='text-sm font-semibold'>节点健康列表</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
按异常数量和资源压力排序。
|
||||
</CardDescription>
|
||||
<CardTitle className='text-sm font-semibold'>{t('title')}</CardTitle>
|
||||
<CardDescription className='text-xs'>{t('description')}</CardDescription>
|
||||
</div>
|
||||
<Button variant='outline' size='sm' className='h-8 text-xs' asChild>
|
||||
<Link href='/nodes'>进入节点页</Link>
|
||||
<Link href='/nodes'>{t('openNodes')}</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sortedNodes.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
title='暂无节点'
|
||||
description='节点接入后,这里会展示系统健康与容量摘要。'
|
||||
title={t('emptyTitle')}
|
||||
description={t('emptyDesc')}
|
||||
/>
|
||||
) : (
|
||||
<div className='border border-dashed rounded-lg overflow-hidden'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className='border-dashed hover:bg-transparent'>
|
||||
<TableHead className='py-2 h-8'>节点</TableHead>
|
||||
<TableHead className='py-2 h-8'>状态</TableHead>
|
||||
<TableHead className='py-2 h-8'>CPU / 内存</TableHead>
|
||||
<TableHead className='py-2 h-8'>请求 / 错误</TableHead>
|
||||
<TableHead className='py-2 h-8'>活动异常</TableHead>
|
||||
<TableHead className='py-2 h-8'>最近心跳</TableHead>
|
||||
<TableHead className='py-2 h-8 text-right'>操作</TableHead>
|
||||
<TableHead className='py-2 h-8'>{t('colNode')}</TableHead>
|
||||
<TableHead className='py-2 h-8'>{t('colStatus')}</TableHead>
|
||||
<TableHead className='py-2 h-8'>{t('colCpuMem')}</TableHead>
|
||||
<TableHead className='py-2 h-8'>{t('colReqErr')}</TableHead>
|
||||
<TableHead className='py-2 h-8'>{t('colEvents')}</TableHead>
|
||||
<TableHead className='py-2 h-8'>{t('colHeartbeat')}</TableHead>
|
||||
<TableHead className='py-2 h-8 text-right'>
|
||||
{t('colActions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -96,18 +99,21 @@ export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
|
||||
{node.node_id}
|
||||
</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{node.geo_name || '未配置地图点位'}
|
||||
{node.geo_name || t('noGeo')}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='py-3'>
|
||||
<div className='flex flex-wrap gap-1.5'>
|
||||
<NodeStatusBadge
|
||||
label={getNodeStatusLabel(node.status)}
|
||||
label={getNodeStatusLabel(node.status, tn)}
|
||||
tone={getNodeStatusTone(node.status)}
|
||||
/>
|
||||
<NodeStatusBadge
|
||||
label={getOpenrestyStatusLabel(node.openresty_status)}
|
||||
label={getOpenrestyStatusLabel(
|
||||
node.openresty_status,
|
||||
tn,
|
||||
)}
|
||||
tone={getOpenrestyStatusTone(node.openresty_status)}
|
||||
/>
|
||||
</div>
|
||||
@@ -130,10 +136,10 @@ export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
|
||||
</TableCell>
|
||||
<TableCell className='py-3 text-xs text-muted-foreground'>
|
||||
{isWSConnectedLastSeen(node.last_seen_at)
|
||||
? 'WS 已连接'
|
||||
? t('wsConnected')
|
||||
: node.last_seen_at
|
||||
? formatRelativeTime(node.last_seen_at)
|
||||
: '暂无'}
|
||||
? formatRelativeTime(node.last_seen_at, tn)
|
||||
: t('na')}
|
||||
</TableCell>
|
||||
<TableCell className='py-3 text-right'>
|
||||
<Button
|
||||
@@ -142,7 +148,9 @@ export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
|
||||
className='h-8 text-xs'
|
||||
asChild
|
||||
>
|
||||
<Link href={`/nodes/detail?id=${node.id}`}>详情</Link>
|
||||
<Link href={`/nodes/detail?id=${node.id}`}>
|
||||
{t('detail')}
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { RankChart } from '@/components/data/rank-chart';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { DashboardNodeHealth } from '@/lib/services/openflare';
|
||||
@@ -27,25 +29,26 @@ function buildNodeRankItems(
|
||||
}
|
||||
|
||||
export function NodeRankPanel({ nodes }: { nodes: DashboardNodeHealth[] }) {
|
||||
const t = useTranslations('dashboard.nodeRank');
|
||||
return (
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-sm font-semibold'>Top 节点榜单</CardTitle>
|
||||
<CardTitle className='text-sm font-semibold'>{t('title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-6'>
|
||||
<div>
|
||||
<p className='mb-3 text-xs tracking-[0.22em] text-muted-foreground uppercase'>
|
||||
流量最高节点
|
||||
{t('traffic')}
|
||||
</p>
|
||||
<RankChart
|
||||
items={buildNodeRankItems(nodes, (node) => node.request_count)}
|
||||
color='#38bdf8'
|
||||
emptyMessage='暂无流量榜单'
|
||||
emptyMessage={t('trafficEmpty')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className='mb-3 text-xs tracking-[0.22em] text-muted-foreground uppercase'>
|
||||
容量压力节点
|
||||
{t('pressure')}
|
||||
</p>
|
||||
<RankChart
|
||||
items={buildNodeRankItems(nodes, (node) =>
|
||||
@@ -59,7 +62,7 @@ export function NodeRankPanel({ nodes }: { nodes: DashboardNodeHealth[] }) {
|
||||
)}
|
||||
color='#ef4444'
|
||||
valueFormatter={(value) => `${value}%`}
|
||||
emptyMessage='暂无容量压力数据'
|
||||
emptyMessage={t('pressureEmpty')}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Activity, Cpu, Gauge, Server, Users } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
@@ -21,6 +22,7 @@ export function DashboardStatCards({
|
||||
traffic: DashboardTraffic;
|
||||
capacity: DashboardCapacity;
|
||||
}) {
|
||||
const t = useTranslations('dashboard.stats');
|
||||
const onlineRate =
|
||||
summary.total_nodes > 0
|
||||
? (summary.online_nodes / summary.total_nodes) * 100
|
||||
@@ -31,7 +33,7 @@ export function DashboardStatCards({
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2'>
|
||||
<span className='text-xs font-medium text-muted-foreground'>
|
||||
24h 请求量
|
||||
{t('requests24h')}
|
||||
</span>
|
||||
<Activity className='size-4 text-primary' />
|
||||
</CardHeader>
|
||||
@@ -40,9 +42,11 @@ export function DashboardStatCards({
|
||||
{formatCompactNumber(traffic.request_count)}
|
||||
</div>
|
||||
<p className='text-[10px] text-muted-foreground'>
|
||||
24h 独立访客 {formatCompactNumber(traffic.unique_visitors)} · 错误{' '}
|
||||
{formatCompactNumber(traffic.error_count)} · 估算 QPS{' '}
|
||||
{traffic.estimated_qps.toFixed(2)}
|
||||
{t('requestsMeta', {
|
||||
visitors: formatCompactNumber(traffic.unique_visitors),
|
||||
errors: formatCompactNumber(traffic.error_count),
|
||||
qps: traffic.estimated_qps.toFixed(2),
|
||||
})}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -50,7 +54,7 @@ export function DashboardStatCards({
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2'>
|
||||
<span className='text-xs font-medium text-muted-foreground'>
|
||||
集群容量
|
||||
{t('clusterCapacity')}
|
||||
</span>
|
||||
<Cpu className='size-4 text-primary' />
|
||||
</CardHeader>
|
||||
@@ -66,9 +70,12 @@ export function DashboardStatCards({
|
||||
className='h-1.5'
|
||||
/>
|
||||
<p className='text-[10px] text-muted-foreground'>
|
||||
平均内存 {formatPercent(capacity.average_memory_usage_percent)} ·
|
||||
高负载节点 CPU {capacity.high_cpu_nodes} / 内存{' '}
|
||||
{capacity.high_memory_nodes} / 存储 {capacity.high_storage_nodes}
|
||||
{t('avgMemoryHighLoad', {
|
||||
memory: formatPercent(capacity.average_memory_usage_percent),
|
||||
cpu: capacity.high_cpu_nodes,
|
||||
memoryNodes: capacity.high_memory_nodes,
|
||||
storage: capacity.high_storage_nodes,
|
||||
})}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -76,7 +83,7 @@ export function DashboardStatCards({
|
||||
<Card className='border-dashed shadow-none md:col-span-2 xl:col-span-1'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2'>
|
||||
<span className='text-xs font-medium text-muted-foreground'>
|
||||
节点概览
|
||||
{t('nodeOverview')}
|
||||
</span>
|
||||
<Server className='size-4 text-primary' />
|
||||
</CardHeader>
|
||||
@@ -85,18 +92,21 @@ export function DashboardStatCards({
|
||||
{summary.online_nodes}
|
||||
<span className='text-sm font-normal text-muted-foreground'>
|
||||
{' '}
|
||||
/ {summary.total_nodes} 在线
|
||||
{t('onlineOf', { total: summary.total_nodes })}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={onlineRate} className='h-1.5' />
|
||||
<p className='text-[10px] text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1'>
|
||||
<span className='inline-flex items-center gap-1'>
|
||||
<Users className='size-3' />
|
||||
待接入 {summary.pending_nodes}
|
||||
{t('pending', { count: summary.pending_nodes })}
|
||||
</span>
|
||||
<span className='inline-flex items-center gap-1'>
|
||||
<Gauge className='size-3' />
|
||||
异常 {summary.unhealthy_nodes} · 离线 {summary.offline_nodes}
|
||||
{t('unhealthyOffline', {
|
||||
unhealthy: summary.unhealthy_nodes,
|
||||
offline: summary.offline_nodes,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { TrendChart } from '@/components/data/trend-chart';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type {
|
||||
@@ -17,11 +19,13 @@ export function TrafficCapacityTrendChart({
|
||||
networkPoints: NetworkTrendPoint[];
|
||||
capacityPoints: CapacityTrendPoint[];
|
||||
}) {
|
||||
const t = useTranslations('dashboard.trafficCapacity');
|
||||
const tc = useTranslations('dashboard.capacityTrend');
|
||||
return (
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-sm font-semibold'>
|
||||
24 小时业务流量与容量趋势
|
||||
{t('title')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-6'>
|
||||
@@ -31,11 +35,11 @@ export function TrafficCapacityTrendChart({
|
||||
)}
|
||||
height={180}
|
||||
summaryScope='total'
|
||||
summaryHint='近 24 小时 · 来自访问日志'
|
||||
summaryHint={t('networkHint')}
|
||||
yAxisValueFormatter={formatBytes}
|
||||
series={[
|
||||
{
|
||||
label: '接收数据',
|
||||
label: t('received'),
|
||||
color: '#22c55e',
|
||||
fillColor: 'rgba(34, 197, 94, 0.14)',
|
||||
variant: 'area',
|
||||
@@ -43,7 +47,7 @@ export function TrafficCapacityTrendChart({
|
||||
valueFormatter: formatBytes,
|
||||
},
|
||||
{
|
||||
label: '已提供数据',
|
||||
label: t('provided'),
|
||||
color: '#38bdf8',
|
||||
values: networkPoints.map((point) => point.bytes_provided),
|
||||
valueFormatter: formatBytes,
|
||||
@@ -57,11 +61,11 @@ export function TrafficCapacityTrendChart({
|
||||
)}
|
||||
height={180}
|
||||
summaryScope='average'
|
||||
summaryHint='近 24 小时 · 宿主机容量 · 平均值'
|
||||
summaryHint={t('capacityHint')}
|
||||
yAxisValueFormatter={formatPercent}
|
||||
series={[
|
||||
{
|
||||
label: '平均 CPU',
|
||||
label: tc('avgCpu'),
|
||||
color: '#0f766e',
|
||||
fillColor: 'rgba(15, 118, 110, 0.15)',
|
||||
variant: 'area',
|
||||
@@ -71,7 +75,7 @@ export function TrafficCapacityTrendChart({
|
||||
valueFormatter: formatPercent,
|
||||
},
|
||||
{
|
||||
label: '平均内存',
|
||||
label: tc('avgMemory'),
|
||||
color: '#2563eb',
|
||||
values: capacityPoints.map(
|
||||
(point) => point.average_memory_usage_percent,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { TrendChart } from '@/components/data/trend-chart';
|
||||
import {
|
||||
Card,
|
||||
@@ -14,18 +16,23 @@ import { formatTrendHour } from './dashboard-utils';
|
||||
|
||||
export function TrafficTrendChart({
|
||||
points,
|
||||
title = '24 小时请求趋势',
|
||||
description = '按小时拆分请求总量与 2xx/4xx/5xx 状态码请求量,判断各状态是否异常抬升。',
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
points: TrafficTrendPoint[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const t = useTranslations('dashboard.trafficTrend');
|
||||
return (
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-sm font-semibold'>{title}</CardTitle>
|
||||
<CardDescription className='text-xs'>{description}</CardDescription>
|
||||
<CardTitle className='text-sm font-semibold'>
|
||||
{title ?? t('title')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
{description ?? t('description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TrendChart
|
||||
@@ -33,27 +40,27 @@ export function TrafficTrendChart({
|
||||
formatTrendHour(point.bucket_started_at),
|
||||
)}
|
||||
summaryScope='total'
|
||||
summaryHint='近 24 小时'
|
||||
summaryHint={t('summaryHint')}
|
||||
series={[
|
||||
{
|
||||
label: '请求量',
|
||||
label: t('requests'),
|
||||
color: '#f59e0b',
|
||||
fillColor: 'rgba(245, 158, 11, 0.18)',
|
||||
variant: 'area',
|
||||
values: points.map((point) => point.request_count),
|
||||
},
|
||||
{
|
||||
label: '2xx 请求',
|
||||
label: t('status2xx'),
|
||||
color: '#22c55e',
|
||||
values: points.map((point) => point.status_2xx_count),
|
||||
},
|
||||
{
|
||||
label: '4xx 请求',
|
||||
label: t('status4xx'),
|
||||
color: '#f97316',
|
||||
values: points.map((point) => point.status_4xx_count),
|
||||
},
|
||||
{
|
||||
label: '5xx 请求',
|
||||
label: t('status5xx'),
|
||||
color: '#ef4444',
|
||||
values: points.map((point) => point.status_5xx_count),
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MapChart, ScatterChart } from 'echarts/charts';
|
||||
import { GeoComponent, TooltipComponent } from 'echarts/components';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
@@ -276,6 +277,8 @@ export function WorldStageMap({
|
||||
nodes: DashboardNodeHealth[];
|
||||
sourceCountries: DistributionItem[];
|
||||
}) {
|
||||
const t = useTranslations('dashboard.map');
|
||||
const tn = useTranslations('nodes');
|
||||
const { resolvedTheme } = useTheme();
|
||||
const isDark = resolvedTheme === 'dark';
|
||||
const router = useRouter();
|
||||
@@ -503,7 +506,7 @@ export function WorldStageMap({
|
||||
) {
|
||||
return [
|
||||
`<div style="font-weight:600;margin-bottom:6px;">${payload.name ?? data.name}</div>`,
|
||||
`<div>最近 24 小时来源请求 ${data.value.toLocaleString('zh-CN')}</div>`,
|
||||
`<div>${t('sourceRequests', { count: data.value.toLocaleString('zh-CN') })}</div>`,
|
||||
].join('');
|
||||
}
|
||||
|
||||
@@ -513,14 +516,22 @@ export function WorldStageMap({
|
||||
|
||||
const locationLine = data.derivedFromGeo
|
||||
? data.geoName
|
||||
: `${data.geoName} · 预设落点`;
|
||||
: `${data.geoName} · ${t('presetPin')}`;
|
||||
|
||||
return [
|
||||
`<div style="font-weight:600;margin-bottom:6px;">${data.name}</div>`,
|
||||
`<div>${locationLine}</div>`,
|
||||
`<div>请求量 ${data.requestCount.toLocaleString('zh-CN')} · 错误数 ${data.errorCount.toLocaleString('zh-CN')}</div>`,
|
||||
`<div>活动事件 ${data.activeEventCount} · 节点状态 ${getNodeStatusLabel(data.status)}</div>`,
|
||||
`<div>OpenResty 状态 ${getOpenrestyStatusLabel(data.openrestyStatus)}</div>`,
|
||||
`<div>${t('requestsErrors', {
|
||||
requests: data.requestCount.toLocaleString('zh-CN'),
|
||||
errors: data.errorCount.toLocaleString('zh-CN'),
|
||||
})}</div>`,
|
||||
`<div>${t('eventsStatus', {
|
||||
events: data.activeEventCount,
|
||||
status: getNodeStatusLabel(data.status, tn),
|
||||
})}</div>`,
|
||||
`<div>${t('openrestyStatus', {
|
||||
status: getOpenrestyStatusLabel(data.openrestyStatus, tn),
|
||||
})}</div>`,
|
||||
].join('');
|
||||
},
|
||||
},
|
||||
@@ -610,6 +621,8 @@ export function WorldStageMap({
|
||||
mapNodes,
|
||||
mapPalette,
|
||||
responsiveMapScale,
|
||||
t,
|
||||
tn,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -617,12 +630,8 @@ export function WorldStageMap({
|
||||
return (
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
<EmptyState
|
||||
title={mapFailed ? '全球地图加载失败' : '全球地图加载中'}
|
||||
description={
|
||||
mapFailed
|
||||
? 'ECharts 世界地图资源未能成功注册,请稍后刷新重试。'
|
||||
: '正在按需初始化全球地图,这一步会延后到首屏内容稳定后执行。'
|
||||
}
|
||||
title={mapFailed ? t('loadFailed') : t('loading')}
|
||||
description={mapFailed ? t('loadFailedDesc') : t('loadingDesc')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -654,8 +663,8 @@ export function WorldStageMap({
|
||||
) : (
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
<EmptyState
|
||||
title='全球地图加载中'
|
||||
description='正在测量地图容器尺寸...'
|
||||
title={t('loading')}
|
||||
description={t('measuring')}
|
||||
iconSize='sm'
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { type ComponentType, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
Activity,
|
||||
Cpu,
|
||||
@@ -40,10 +41,10 @@ const WorldStageMap = dynamic(
|
||||
);
|
||||
|
||||
const LEGEND_ITEMS = [
|
||||
{ dot: 'bg-blue-500', label: '来源' },
|
||||
{ dot: 'bg-emerald-500', label: '正常' },
|
||||
{ dot: 'bg-amber-500', label: '承压' },
|
||||
{ dot: 'bg-destructive', label: '异常' },
|
||||
{ dot: 'bg-blue-500', key: 'legendSource' },
|
||||
{ dot: 'bg-emerald-500', key: 'legendHealthy' },
|
||||
{ dot: 'bg-amber-500', key: 'legendPressure' },
|
||||
{ dot: 'bg-destructive', key: 'legendUnhealthy' },
|
||||
] as const;
|
||||
|
||||
function SummaryMetric({
|
||||
@@ -128,6 +129,7 @@ export function WorldStage({
|
||||
nodes: DashboardNodeHealth[];
|
||||
sourceCountries: DistributionItem[];
|
||||
}) {
|
||||
const t = useTranslations('dashboard.world');
|
||||
const mapViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const [shouldRenderMap, setShouldRenderMap] = useState(false);
|
||||
|
||||
@@ -177,62 +179,71 @@ export function WorldStage({
|
||||
|
||||
const mapModeLabel =
|
||||
sourceCountries.length > 0
|
||||
? '访客来源'
|
||||
? t('modeVisitorSource')
|
||||
: geoConfiguredNodes > 0
|
||||
? '节点坐标'
|
||||
: '覆盖信号';
|
||||
? t('modeNodeCoords')
|
||||
: t('modeCoverage');
|
||||
|
||||
const headlineMetrics = [
|
||||
{
|
||||
label: '在线覆盖',
|
||||
label: t('onlineCoverage'),
|
||||
value: formatPercent(onlineRate),
|
||||
hint: `${summary.online_nodes}/${summary.total_nodes} 在线`,
|
||||
hint: t('onlineHint', {
|
||||
online: summary.online_nodes,
|
||||
total: summary.total_nodes,
|
||||
}),
|
||||
icon: Server,
|
||||
progress: onlineRate,
|
||||
},
|
||||
{
|
||||
label: '运行健康',
|
||||
label: t('runHealth'),
|
||||
value: formatPercent(healthyRate),
|
||||
hint: `${summary.unhealthy_nodes} 个异常`,
|
||||
hint: t('unhealthyCount', { count: summary.unhealthy_nodes }),
|
||||
icon: ShieldCheck,
|
||||
progress: healthyRate,
|
||||
},
|
||||
{
|
||||
label: '24小时请求',
|
||||
label: t('requests24h'),
|
||||
value: formatCompactNumber(traffic.request_count),
|
||||
hint: `QPS ${traffic.estimated_qps.toFixed(1)} · ${traffic.reported_nodes} 个节点上报`,
|
||||
hint: t('qpsReported', {
|
||||
qps: traffic.estimated_qps.toFixed(1),
|
||||
count: traffic.reported_nodes,
|
||||
}),
|
||||
icon: Activity,
|
||||
},
|
||||
{
|
||||
label: '节点坐标',
|
||||
label: t('nodeCoords'),
|
||||
value: formatCompactNumber(geoConfiguredNodes),
|
||||
hint:
|
||||
geoConfiguredNodes > 0
|
||||
? `${sourceCountries.length} 个来源国家`
|
||||
: '未配置时使用预设落点',
|
||||
? t('sourceCountries', { count: sourceCountries.length })
|
||||
: t('fallbackPins'),
|
||||
icon: Network,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const detailMetrics = [
|
||||
{
|
||||
label: '平均 CPU',
|
||||
label: t('avgCpu'),
|
||||
value: formatPercent(capacity.average_cpu_usage_percent),
|
||||
hint: `${capacity.high_cpu_nodes} 个偏高`,
|
||||
hint: t('highCount', { count: capacity.high_cpu_nodes }),
|
||||
icon: Cpu,
|
||||
progress: capacity.average_cpu_usage_percent,
|
||||
},
|
||||
{
|
||||
label: '平均内存',
|
||||
label: t('avgMemory'),
|
||||
value: formatPercent(capacity.average_memory_usage_percent),
|
||||
hint: `${capacity.high_memory_nodes} 个偏高`,
|
||||
hint: t('highCount', { count: capacity.high_memory_nodes }),
|
||||
icon: MemoryStick,
|
||||
progress: capacity.average_memory_usage_percent,
|
||||
},
|
||||
{
|
||||
label: '高存储',
|
||||
label: t('highStorage'),
|
||||
value: formatCompactNumber(capacity.high_storage_nodes),
|
||||
hint: `${summary.offline_nodes} 离线 · ${summary.pending_nodes} 待接入`,
|
||||
hint: t('offlinePending', {
|
||||
offline: summary.offline_nodes,
|
||||
pending: summary.pending_nodes,
|
||||
}),
|
||||
icon: HardDrive,
|
||||
},
|
||||
] as const;
|
||||
@@ -244,7 +255,7 @@ export function WorldStage({
|
||||
<div className='min-w-0'>
|
||||
<CardTitle className='flex items-center gap-1.5 text-sm font-semibold'>
|
||||
<Globe2 className='size-4 shrink-0 text-primary' />
|
||||
全球态势板
|
||||
{t('title')}
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='ml-1 text-[10px] font-normal text-muted-foreground'
|
||||
@@ -253,17 +264,17 @@ export function WorldStage({
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription className='mt-1 text-xs'>
|
||||
汇总节点分布、来源国家热度与最近窗口运行状态。
|
||||
{t('description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center gap-x-3 gap-y-1 rounded-full border border-dashed bg-muted/15 px-3 py-1.5'>
|
||||
{LEGEND_ITEMS.map((item) => (
|
||||
<span
|
||||
key={item.label}
|
||||
key={item.key}
|
||||
className='inline-flex items-center gap-1 text-[10px] text-muted-foreground'
|
||||
>
|
||||
<span className={cn('size-1.5 rounded-full', item.dot)} />
|
||||
{item.label}
|
||||
{t(item.key)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -291,8 +302,8 @@ export function WorldStage({
|
||||
) : (
|
||||
<div className='flex h-full items-center justify-center px-4'>
|
||||
<EmptyState
|
||||
title='地图准备中'
|
||||
description='进入可视区域后加载'
|
||||
title={t('mapPreparing')}
|
||||
description={t('mapPreparingDesc')}
|
||||
iconSize='sm'
|
||||
/>
|
||||
</div>
|
||||
@@ -301,7 +312,7 @@ export function WorldStage({
|
||||
{shouldRenderMap && nodes.length === 0 ? (
|
||||
<div className='pointer-events-none absolute inset-x-3 bottom-2 z-10'>
|
||||
<p className='rounded-md border border-dashed bg-background/90 px-2.5 py-1.5 text-[10px] text-muted-foreground backdrop-blur-sm'>
|
||||
暂无节点,接入后将展示地理分布
|
||||
{t('noNodesOverlay')}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user