mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 23:56:37 +08:00
feat: add geo metadata to nodes and dashboard
- Extend DashboardNodeHealth and NodeItem interfaces to include geo_name, geo_latitude, and geo_longitude. - Update NodeDetailPage and NodesPage components to handle geo metadata in forms and payloads. - Implement validation for geo fields in NodesPage using Zod. - Enhance dashboard overview tests to include geo metadata for nodes. - Create WorldStage component to visualize node health and geo locations on a world map. - Update design and development documentation to reflect the addition of geo metadata for nodes.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import Link from 'next/link';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { RankChart } from '@/components/data/rank-chart';
|
||||
import { TrendChart } from '@/components/data/trend-chart';
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
@@ -15,6 +16,7 @@ import type {
|
||||
DashboardNodeHealth,
|
||||
DashboardPeakNode,
|
||||
} from '@/features/dashboard/types';
|
||||
import { WorldStage } from '@/features/dashboard/components/world-stage';
|
||||
import {
|
||||
getNodeStatusLabel,
|
||||
getNodeStatusVariant,
|
||||
@@ -30,6 +32,21 @@ function formatPercent(value?: number | null) {
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatBytes(value?: number | null) {
|
||||
if (!value || value <= 0) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let current = value;
|
||||
let index = 0;
|
||||
while (current >= 1024 && index < units.length - 1) {
|
||||
current /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
return `${current.toFixed(current >= 100 || index === 0 ? 0 : 1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
@@ -42,6 +59,20 @@ function formatTrendHour(value: string) {
|
||||
return `${date.getHours().toString().padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
function formatPeakHour(value: string) {
|
||||
if (!value) {
|
||||
return '暂无';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '暂无';
|
||||
}
|
||||
return `${formatTrendHour(value)} - ${date
|
||||
.getMinutes()
|
||||
.toString()
|
||||
.padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function getAlertVariant(
|
||||
alert: DashboardAlert,
|
||||
): 'success' | 'warning' | 'danger' | 'info' {
|
||||
@@ -57,18 +88,89 @@ function getAlertVariant(
|
||||
return 'info';
|
||||
}
|
||||
|
||||
function formatPeakHour(value: string) {
|
||||
if (!value) {
|
||||
return '暂无';
|
||||
function formatPeakNode(node: DashboardPeakNode | null) {
|
||||
if (!node) {
|
||||
return {
|
||||
title: '暂无',
|
||||
hint: '当前没有可用节点数据',
|
||||
};
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '暂无';
|
||||
return {
|
||||
title: node.node_name,
|
||||
hint: `请求 ${node.request_count} · 错误 ${node.error_count} · CPU ${formatPercent(node.cpu_usage_percent)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function buildNodeDetailHref(id?: number | null) {
|
||||
if (!id) {
|
||||
return '/node';
|
||||
}
|
||||
return `${formatTrendHour(value)} - ${date
|
||||
.getMinutes()
|
||||
.toString()
|
||||
.padStart(2, '0')}`;
|
||||
return `/node/detail?id=${id}`;
|
||||
}
|
||||
|
||||
function buildNodeRankItems(
|
||||
nodes: DashboardNodeHealth[],
|
||||
selector: (node: DashboardNodeHealth) => number,
|
||||
limit = 5,
|
||||
) {
|
||||
return [...nodes]
|
||||
.sort((left, right) => {
|
||||
const leftValue = selector(left);
|
||||
const rightValue = selector(right);
|
||||
if (leftValue === rightValue) {
|
||||
return left.name.localeCompare(right.name, 'zh-CN');
|
||||
}
|
||||
return rightValue - leftValue;
|
||||
})
|
||||
.slice(0, limit)
|
||||
.filter((node) => selector(node) > 0)
|
||||
.map((node) => ({
|
||||
label: node.name,
|
||||
value: selector(node),
|
||||
}));
|
||||
}
|
||||
|
||||
function OverviewMetric({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
accent = 'sky',
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
hint?: string;
|
||||
accent?: 'sky' | 'amber' | 'emerald' | 'rose';
|
||||
}) {
|
||||
const accentClass =
|
||||
accent === 'amber'
|
||||
? 'from-amber-400/14 to-transparent'
|
||||
: accent === 'emerald'
|
||||
? 'from-emerald-400/14 to-transparent'
|
||||
: accent === 'rose'
|
||||
? 'from-rose-400/14 to-transparent'
|
||||
: 'from-sky-400/14 to-transparent';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-3xl border border-[var(--border-default)] bg-[linear-gradient(180deg,var(--surface-elevated),var(--surface-card))] px-5 py-5 shadow-[var(--shadow-soft)]`}
|
||||
>
|
||||
<div className={`rounded-2xl bg-gradient-to-r ${accentClass} p-0.5`}>
|
||||
<div className="rounded-[15px] bg-transparent">
|
||||
<p className="text-xs tracking-[0.24em] text-[var(--foreground-muted)] uppercase">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-3 text-3xl font-semibold text-[var(--foreground-primary)]">
|
||||
{value}
|
||||
</p>
|
||||
{hint ? (
|
||||
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">
|
||||
{hint}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RiskSignal({
|
||||
@@ -86,23 +188,25 @@ function RiskSignal({
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === 'danger'
|
||||
? 'border-rose-400/30 bg-rose-500/10 text-rose-100'
|
||||
? 'border-rose-400/28 bg-rose-500/10 text-rose-100'
|
||||
: tone === 'warning'
|
||||
? 'border-amber-400/30 bg-amber-500/10 text-amber-100'
|
||||
? 'border-amber-400/28 bg-amber-500/10 text-amber-100'
|
||||
: tone === 'success'
|
||||
? 'border-emerald-400/30 bg-emerald-500/10 text-emerald-100'
|
||||
: 'border-sky-400/30 bg-sky-500/10 text-sky-100';
|
||||
? 'border-emerald-400/28 bg-emerald-500/10 text-emerald-100'
|
||||
: 'border-sky-400/28 bg-sky-500/10 text-sky-100';
|
||||
|
||||
const content = (
|
||||
<div className={`rounded-3xl border px-4 py-4 ${toneClass}`}>
|
||||
<p className="text-xs tracking-[0.22em] uppercase opacity-75">{label}</p>
|
||||
<p className="text-xs tracking-[0.22em] opacity-75 uppercase">{label}</p>
|
||||
<p className="mt-3 text-3xl font-semibold">{value}</p>
|
||||
<p className="mt-2 text-sm opacity-80">{hint}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!href) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className="block transition hover:opacity-90">
|
||||
{content}
|
||||
@@ -145,52 +249,6 @@ function PeakCard({
|
||||
);
|
||||
}
|
||||
|
||||
function formatPeakNode(node: DashboardPeakNode | null) {
|
||||
if (!node) {
|
||||
return {
|
||||
title: '暂无',
|
||||
hint: '当前没有可用节点数据',
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: node.node_name,
|
||||
hint: `请求 ${node.request_count} · 错误 ${node.error_count} · CPU ${formatPercent(node.cpu_usage_percent)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function buildNodeDetailHref(id?: number | null) {
|
||||
if (!id) {
|
||||
return '/node';
|
||||
}
|
||||
return `/node/detail?id=${id}`;
|
||||
}
|
||||
|
||||
function OverviewMetric({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-5 py-5">
|
||||
<p className="text-xs tracking-[0.24em] text-[var(--foreground-muted)] uppercase">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-3 text-3xl font-semibold text-[var(--foreground-primary)]">
|
||||
{value}
|
||||
</p>
|
||||
{hint ? (
|
||||
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">
|
||||
{hint}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeHealthRow({ node }: { node: DashboardNodeHealth }) {
|
||||
return (
|
||||
<Link
|
||||
@@ -240,7 +298,7 @@ function NodeHealthRow({ node }: { node: DashboardNodeHealth }) {
|
||||
最近窗口请求
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{node.request_count}
|
||||
{node.request_count.toLocaleString('zh-CN')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -302,50 +360,98 @@ export function DashboardOverview() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<AppCard
|
||||
title="系统运行总览"
|
||||
description="优先回答当前系统是否健康、容量是否紧张、是否存在配置落后和异常节点。"
|
||||
action={
|
||||
<div className="text-right text-sm text-[var(--foreground-secondary)]">
|
||||
生成时间:{formatDateTime(overview.generated_at)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-5">
|
||||
<OverviewMetric
|
||||
label="在线节点"
|
||||
value={`${overview.summary.online_nodes}/${overview.summary.total_nodes}`}
|
||||
hint={`${overview.summary.offline_nodes} 离线 · ${overview.summary.pending_nodes} 待接入`}
|
||||
/>
|
||||
<OverviewMetric
|
||||
label="活动异常"
|
||||
value={overview.summary.active_alerts}
|
||||
hint={`${overview.summary.unhealthy_nodes} 个 OpenResty 不健康`}
|
||||
/>
|
||||
<OverviewMetric
|
||||
label="配置落后"
|
||||
value={overview.summary.lagging_nodes}
|
||||
hint={overview.config.active_version || '当前无激活版本'}
|
||||
/>
|
||||
<OverviewMetric
|
||||
label="最近窗口请求"
|
||||
value={overview.traffic.request_count}
|
||||
hint={`QPS ${overview.traffic.estimated_qps.toFixed(1)} · UV ${overview.traffic.unique_visitors}`}
|
||||
/>
|
||||
<OverviewMetric
|
||||
label="平均 CPU"
|
||||
value={formatPercent(overview.capacity.average_cpu_usage_percent)}
|
||||
hint={`${overview.capacity.high_cpu_nodes} 个高 CPU 节点`}
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
<WorldStage
|
||||
generatedAt={overview.generated_at}
|
||||
summary={overview.summary}
|
||||
risk={overview.risk}
|
||||
config={overview.config}
|
||||
nodes={overview.nodes}
|
||||
sourceCountries={overview.distributions.source_countries}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
|
||||
<AppCard
|
||||
title="风险态势"
|
||||
description="把异常、容量压力和配置落后聚到同一层,方便先确定今天该盯哪里。"
|
||||
title="系统健康摘要"
|
||||
description="先回答系统是否健康、容量是否紧张、是否存在追平偏差和异常流量。"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<OverviewMetric
|
||||
label="在线节点"
|
||||
value={`${overview.summary.online_nodes}/${overview.summary.total_nodes}`}
|
||||
hint={`${overview.summary.offline_nodes} 离线 · ${overview.summary.pending_nodes} 待接入`}
|
||||
accent="sky"
|
||||
/>
|
||||
<OverviewMetric
|
||||
label="活动异常"
|
||||
value={overview.summary.active_alerts}
|
||||
hint={`${overview.summary.unhealthy_nodes} 个 OpenResty 不健康`}
|
||||
accent="rose"
|
||||
/>
|
||||
<OverviewMetric
|
||||
label="配置落后"
|
||||
value={overview.summary.lagging_nodes}
|
||||
hint={overview.config.active_version || '当前无激活版本'}
|
||||
accent="amber"
|
||||
/>
|
||||
<OverviewMetric
|
||||
label="最近窗口请求"
|
||||
value={overview.traffic.request_count.toLocaleString('zh-CN')}
|
||||
hint={`QPS ${overview.traffic.estimated_qps.toFixed(1)} · UV ${overview.traffic.unique_visitors.toLocaleString('zh-CN')}`}
|
||||
accent="emerald"
|
||||
/>
|
||||
<OverviewMetric
|
||||
label="平均 CPU"
|
||||
value={formatPercent(overview.capacity.average_cpu_usage_percent)}
|
||||
hint={`${overview.capacity.high_cpu_nodes} 个高 CPU 节点`}
|
||||
accent="amber"
|
||||
/>
|
||||
<OverviewMetric
|
||||
label="平均内存"
|
||||
value={formatPercent(overview.capacity.average_memory_usage_percent)}
|
||||
hint={`${overview.capacity.high_memory_nodes} 个高内存节点`}
|
||||
accent="sky"
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="峰值与风险摘要"
|
||||
description="快速回答什么时候最忙、哪里最危险,以及首页能直接行动的入口。"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<PeakCard
|
||||
label="请求峰值时段"
|
||||
value={formatPeakHour(
|
||||
overview.peaks.peak_request_hour.bucket_started_at,
|
||||
)}
|
||||
hint={`峰值请求 ${overview.peaks.peak_request_hour.request_count.toLocaleString('zh-CN')}`}
|
||||
/>
|
||||
<PeakCard
|
||||
label="错误峰值时段"
|
||||
value={formatPeakHour(
|
||||
overview.peaks.peak_error_hour.bucket_started_at,
|
||||
)}
|
||||
hint={`峰值错误 ${overview.peaks.peak_error_hour.error_count.toLocaleString('zh-CN')}`}
|
||||
/>
|
||||
<PeakCard
|
||||
label="最忙节点"
|
||||
value={formatPeakNode(overview.peaks.busiest_node).title}
|
||||
hint={formatPeakNode(overview.peaks.busiest_node).hint}
|
||||
href={buildNodeDetailHref(
|
||||
nodeIdMap.get(overview.peaks.busiest_node?.node_id ?? ''),
|
||||
)}
|
||||
/>
|
||||
<PeakCard
|
||||
label="优先排查节点"
|
||||
value={formatPeakNode(overview.peaks.riskiest_node).title}
|
||||
hint={formatPeakNode(overview.peaks.riskiest_node).hint}
|
||||
href={buildNodeDetailHref(
|
||||
nodeIdMap.get(overview.peaks.riskiest_node?.node_id ?? ''),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<RiskSignal
|
||||
label="Critical"
|
||||
value={overview.risk.critical_alerts}
|
||||
@@ -380,12 +486,6 @@ export function DashboardOverview() {
|
||||
tone="warning"
|
||||
hint={`${overview.risk.high_cpu_nodes} 个高 CPU · ${overview.risk.high_memory_nodes} 个高内存`}
|
||||
/>
|
||||
<RiskSignal
|
||||
label="高存储压力"
|
||||
value={overview.risk.high_storage_nodes}
|
||||
tone="warning"
|
||||
hint={`${overview.risk.offline_nodes} 个离线节点`}
|
||||
/>
|
||||
<RiskSignal
|
||||
label="离线节点"
|
||||
value={overview.risk.offline_nodes}
|
||||
@@ -395,44 +495,6 @@ export function DashboardOverview() {
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="峰值摘要"
|
||||
description="快速回答什么时候最忙、什么时候最危险,以及该先看哪台节点。"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<PeakCard
|
||||
label="请求峰值时段"
|
||||
value={formatPeakHour(
|
||||
overview.peaks.peak_request_hour.bucket_started_at,
|
||||
)}
|
||||
hint={`峰值请求 ${overview.peaks.peak_request_hour.request_count}`}
|
||||
/>
|
||||
<PeakCard
|
||||
label="错误峰值时段"
|
||||
value={formatPeakHour(
|
||||
overview.peaks.peak_error_hour.bucket_started_at,
|
||||
)}
|
||||
hint={`峰值错误 ${overview.peaks.peak_error_hour.error_count}`}
|
||||
/>
|
||||
<PeakCard
|
||||
label="最忙节点"
|
||||
value={formatPeakNode(overview.peaks.busiest_node).title}
|
||||
hint={formatPeakNode(overview.peaks.busiest_node).hint}
|
||||
href={buildNodeDetailHref(
|
||||
nodeIdMap.get(overview.peaks.busiest_node?.node_id ?? ''),
|
||||
)}
|
||||
/>
|
||||
<PeakCard
|
||||
label="优先排查节点"
|
||||
value={formatPeakNode(overview.peaks.riskiest_node).title}
|
||||
hint={formatPeakNode(overview.peaks.riskiest_node).hint}
|
||||
href={buildNodeDetailHref(
|
||||
nodeIdMap.get(overview.peaks.riskiest_node?.node_id ?? ''),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
@@ -497,99 +559,290 @@ export function DashboardOverview() {
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||
<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
|
||||
<AppCard
|
||||
title="节点健康列表"
|
||||
description="按异常数量和资源压力排序,优先显示最需要关注的节点。"
|
||||
action={
|
||||
<Link
|
||||
href="/node"
|
||||
className="inline-flex items-center rounded-full border border-[var(--border-default)] px-3 py-1.5 text-xs text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
>
|
||||
进入节点页
|
||||
</Link>
|
||||
}
|
||||
title="24 小时网络与磁盘趋势"
|
||||
description="把 OpenResty 吞吐和磁盘读写放到同一屏,方便判断是流量拉升还是资源抖动。"
|
||||
>
|
||||
{overview.nodes.length === 0 ? (
|
||||
<div className="space-y-6">
|
||||
<TrendChart
|
||||
labels={overview.trends.network_24h.map((point) =>
|
||||
formatTrendHour(point.bucket_started_at),
|
||||
)}
|
||||
height={180}
|
||||
series={[
|
||||
{
|
||||
label: 'OpenResty 入站',
|
||||
color: '#22c55e',
|
||||
fillColor: 'rgba(34, 197, 94, 0.14)',
|
||||
variant: 'area',
|
||||
values: overview.trends.network_24h.map(
|
||||
(point) => point.openresty_rx_bytes,
|
||||
),
|
||||
valueFormatter: formatBytes,
|
||||
},
|
||||
{
|
||||
label: 'OpenResty 出站',
|
||||
color: '#38bdf8',
|
||||
values: overview.trends.network_24h.map(
|
||||
(point) => point.openresty_tx_bytes,
|
||||
),
|
||||
valueFormatter: formatBytes,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<TrendChart
|
||||
labels={overview.trends.disk_io_24h.map((point) =>
|
||||
formatTrendHour(point.bucket_started_at),
|
||||
)}
|
||||
height={180}
|
||||
series={[
|
||||
{
|
||||
label: '磁盘读',
|
||||
color: '#a78bfa',
|
||||
fillColor: 'rgba(167, 139, 250, 0.14)',
|
||||
variant: 'area',
|
||||
values: overview.trends.disk_io_24h.map(
|
||||
(point) => point.disk_read_bytes,
|
||||
),
|
||||
valueFormatter: formatBytes,
|
||||
},
|
||||
{
|
||||
label: '磁盘写',
|
||||
color: '#fb7185',
|
||||
values: overview.trends.disk_io_24h.map(
|
||||
(point) => point.disk_write_bytes,
|
||||
),
|
||||
valueFormatter: formatBytes,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="活动异常"
|
||||
description="优先展示当前仍在触发中的问题。"
|
||||
>
|
||||
{overview.active_alerts.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无节点"
|
||||
description="节点接入后,这里会展示系统健康与容量摘要。"
|
||||
title="暂无活动异常"
|
||||
description="当前系统没有正在触发的节点健康事件。"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{overview.nodes.slice(0, 8).map((node) => (
|
||||
<NodeHealthRow key={node.node_id} node={node} />
|
||||
<div className="space-y-3">
|
||||
{overview.active_alerts.map((alert) => (
|
||||
<Link
|
||||
key={`${alert.node_id}-${alert.event_type}-${alert.last_triggered_at}`}
|
||||
href={buildNodeDetailHref(nodeIdMap.get(alert.node_id))}
|
||||
className="block rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 transition hover:border-[var(--border-strong)] hover:bg-[var(--surface-muted)]"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge
|
||||
label={alert.event_type.replaceAll('_', ' ')}
|
||||
variant={getAlertVariant(alert)}
|
||||
/>
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{alert.node_name}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">
|
||||
{alert.message || '暂无详细消息'}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-[var(--foreground-muted)]">
|
||||
{formatRelativeTime(alert.last_triggered_at)} ·{' '}
|
||||
{formatDateTime(alert.last_triggered_at)}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<AppCard
|
||||
title="流量与容量"
|
||||
description="聚合最近窗口流量和资源压力,作为首页一级信号。"
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<OverviewMetric
|
||||
label="最近窗口错误"
|
||||
value={overview.traffic.error_count}
|
||||
hint={`${overview.traffic.reported_nodes} 个节点上报`}
|
||||
/>
|
||||
<OverviewMetric
|
||||
label="平均内存"
|
||||
value={formatPercent(
|
||||
overview.capacity.average_memory_usage_percent,
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<AppCard
|
||||
title="来源分布"
|
||||
description="聚合最近 24 小时主要来源国家,优先识别流量重心变化。"
|
||||
>
|
||||
<RankChart
|
||||
items={overview.distributions.source_countries.map((item) => ({
|
||||
label: item.key,
|
||||
value: item.value,
|
||||
}))}
|
||||
color="#38bdf8"
|
||||
emptyMessage="暂无来源分布数据"
|
||||
/>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="状态码分布"
|
||||
description="快速判断成功响应是否仍是主流,以及错误码是否有抬升。"
|
||||
>
|
||||
<RankChart
|
||||
items={overview.distributions.status_codes.map((item) => ({
|
||||
label: `HTTP ${item.key}`,
|
||||
value: item.value,
|
||||
}))}
|
||||
color="#f59e0b"
|
||||
emptyMessage="暂无状态码分布"
|
||||
/>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="Top Domain"
|
||||
description="观察主要流量集中在哪些域名,方便判断业务热度与异常集中点。"
|
||||
>
|
||||
<RankChart
|
||||
items={overview.distributions.top_domains.map((item) => ({
|
||||
label: item.key,
|
||||
value: item.value,
|
||||
}))}
|
||||
color="#34d399"
|
||||
emptyMessage="暂无域名分布"
|
||||
/>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||
<AppCard
|
||||
title="Top 节点榜单"
|
||||
description="把流量峰值、错误热点和容量压力节点并排展示,方便值守时快速判断先处理谁。"
|
||||
>
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<div>
|
||||
<p className="mb-3 text-xs tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
|
||||
流量最高节点
|
||||
</p>
|
||||
<RankChart
|
||||
items={buildNodeRankItems(
|
||||
overview.nodes,
|
||||
(node) => node.request_count,
|
||||
)}
|
||||
hint={`${overview.capacity.high_memory_nodes} 个高内存节点`}
|
||||
/>
|
||||
<OverviewMetric
|
||||
label="高存储压力"
|
||||
value={overview.capacity.high_storage_nodes}
|
||||
hint="存储使用率 >= 85%"
|
||||
color="#38bdf8"
|
||||
emptyMessage="暂无流量榜单"
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="活动异常"
|
||||
description="优先展示当前仍在触发中的问题。"
|
||||
>
|
||||
{overview.active_alerts.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无活动异常"
|
||||
description="当前系统没有正在触发的节点健康事件。"
|
||||
<div>
|
||||
<p className="mb-3 text-xs tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
|
||||
错误热点节点
|
||||
</p>
|
||||
<RankChart
|
||||
items={buildNodeRankItems(
|
||||
overview.nodes,
|
||||
(node) => node.error_count,
|
||||
)}
|
||||
color="#f97316"
|
||||
emptyMessage="暂无错误热点"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{overview.active_alerts.map((alert) => (
|
||||
<Link
|
||||
key={`${alert.node_id}-${alert.event_type}-${alert.last_triggered_at}`}
|
||||
href={buildNodeDetailHref(nodeIdMap.get(alert.node_id))}
|
||||
className="block rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 transition hover:border-[var(--border-strong)] hover:bg-[var(--surface-muted)]"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge
|
||||
label={alert.event_type.replaceAll('_', ' ')}
|
||||
variant={getAlertVariant(alert)}
|
||||
/>
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{alert.node_name}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">
|
||||
{alert.message || '暂无详细消息'}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-[var(--foreground-muted)]">
|
||||
{formatRelativeTime(alert.last_triggered_at)} ·{' '}
|
||||
{formatDateTime(alert.last_triggered_at)}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-3 text-xs tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
|
||||
容量压力节点
|
||||
</p>
|
||||
<RankChart
|
||||
items={buildNodeRankItems(
|
||||
overview.nodes,
|
||||
(node) =>
|
||||
Math.round(
|
||||
Math.max(
|
||||
node.cpu_usage_percent,
|
||||
node.memory_usage_percent,
|
||||
node.storage_usage_percent,
|
||||
),
|
||||
),
|
||||
)}
|
||||
color="#ef4444"
|
||||
valueFormatter={(value) => `${value}%`}
|
||||
emptyMessage="暂无容量压力数据"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="处置建议"
|
||||
description="把首页信号翻译成可行动的下一步,降低值守判断成本。"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-3xl border border-rose-400/18 bg-rose-500/8 px-4 py-4">
|
||||
<p className="text-xs tracking-[0.22em] text-rose-200 uppercase">
|
||||
第一优先级
|
||||
</p>
|
||||
<p className="mt-3 text-lg font-semibold text-[var(--foreground-primary)]">
|
||||
先处理活动异常与 OpenResty 不健康节点
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">
|
||||
当前有 {overview.summary.active_alerts} 个活动异常,{` `}
|
||||
{overview.summary.unhealthy_nodes} 个 OpenResty 不健康节点。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-amber-400/18 bg-amber-500/8 px-4 py-4">
|
||||
<p className="text-xs tracking-[0.22em] text-amber-200 uppercase">
|
||||
第二优先级
|
||||
</p>
|
||||
<p className="mt-3 text-lg font-semibold text-[var(--foreground-primary)]">
|
||||
检查配置落后与容量逼近节点
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">
|
||||
当前有 {overview.summary.lagging_nodes} 个配置落后节点,{` `}
|
||||
{overview.capacity.high_cpu_nodes +
|
||||
overview.capacity.high_memory_nodes +
|
||||
overview.capacity.high_storage_nodes}{' '}
|
||||
个节点出现明显容量压力。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-sky-400/18 bg-sky-500/8 px-4 py-4">
|
||||
<p className="text-xs tracking-[0.22em] text-sky-200 uppercase">
|
||||
观察项
|
||||
</p>
|
||||
<p className="mt-3 text-lg font-semibold text-[var(--foreground-primary)]">
|
||||
关注峰值时段与来源分布变化
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">
|
||||
请求峰值出现在 {formatPeakHour(
|
||||
overview.peaks.peak_request_hour.bucket_started_at,
|
||||
)}
|
||||
,当前主要来源集中在
|
||||
{overview.distributions.source_countries
|
||||
.slice(0, 2)
|
||||
.map((item) => item.key)
|
||||
.join(' / ') || ' 暂无来源数据'}
|
||||
。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<AppCard
|
||||
title="节点健康列表"
|
||||
description="按异常数量和资源压力排序,优先显示最需要关注的节点。"
|
||||
action={
|
||||
<Link
|
||||
href="/node"
|
||||
className="inline-flex items-center rounded-full border border-[var(--border-default)] px-3 py-1.5 text-xs text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
>
|
||||
进入节点页
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{overview.nodes.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无节点"
|
||||
description="节点接入后,这里会展示系统健康与容量摘要。"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{overview.nodes.slice(0, 8).map((node) => (
|
||||
<NodeHealthRow key={node.node_id} node={node} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import type {
|
||||
DashboardConfig,
|
||||
DashboardNodeHealth,
|
||||
DashboardRiskSummary,
|
||||
DashboardSummary,
|
||||
DistributionItem,
|
||||
} from '@/features/dashboard/types';
|
||||
import {
|
||||
getNodeStatusLabel,
|
||||
getNodeStatusVariant,
|
||||
getOpenrestyStatusLabel,
|
||||
getOpenrestyStatusVariant,
|
||||
} from '@/features/nodes/utils';
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
const stageAnchors = [
|
||||
{ left: '16%', top: '24%' },
|
||||
{ left: '24%', top: '58%' },
|
||||
{ left: '45%', top: '22%' },
|
||||
{ left: '52%', top: '54%' },
|
||||
{ left: '72%', top: '30%' },
|
||||
{ left: '84%', top: '68%' },
|
||||
];
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function formatPercent(value: number) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return '0%';
|
||||
}
|
||||
return `${value.toFixed(value >= 100 ? 0 : 1)}%`;
|
||||
}
|
||||
|
||||
function buildNodeDetailHref(id?: number | null) {
|
||||
if (!id) {
|
||||
return '/node';
|
||||
}
|
||||
return `/node/detail?id=${id}`;
|
||||
}
|
||||
|
||||
function projectNodeCoordinates(node: DashboardNodeHealth, index: number) {
|
||||
if (
|
||||
typeof node.geo_latitude === 'number' &&
|
||||
typeof node.geo_longitude === 'number'
|
||||
) {
|
||||
const left = clamp(((node.geo_longitude + 180) / 360) * 100, 8, 92);
|
||||
const top = clamp(((90 - node.geo_latitude) / 180) * 100, 12, 78);
|
||||
return {
|
||||
left: `${left}%`,
|
||||
top: `${top}%`,
|
||||
derivedFromGeo: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...stageAnchors[index % stageAnchors.length],
|
||||
derivedFromGeo: false,
|
||||
};
|
||||
}
|
||||
|
||||
function getNodeSignalTone(node: DashboardNodeHealth) {
|
||||
if (
|
||||
node.status === 'offline' ||
|
||||
node.openresty_status === 'unhealthy' ||
|
||||
node.active_event_count > 0
|
||||
) {
|
||||
return {
|
||||
dot: 'bg-rose-400 shadow-[0_0_18px_rgba(251,113,133,0.75)]',
|
||||
ring: 'border-rose-400/50 bg-rose-500/12 text-rose-50',
|
||||
halo: 'bg-rose-500/20',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
node.cpu_usage_percent >= 80 ||
|
||||
node.memory_usage_percent >= 85 ||
|
||||
node.storage_usage_percent >= 85
|
||||
) {
|
||||
return {
|
||||
dot: 'bg-amber-300 shadow-[0_0_18px_rgba(252,211,77,0.75)]',
|
||||
ring: 'border-amber-300/45 bg-amber-400/12 text-amber-50',
|
||||
halo: 'bg-amber-400/18',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
dot: 'bg-emerald-300 shadow-[0_0_18px_rgba(110,231,183,0.7)]',
|
||||
ring: 'border-emerald-300/40 bg-emerald-400/12 text-emerald-50',
|
||||
halo: 'bg-emerald-400/18',
|
||||
};
|
||||
}
|
||||
|
||||
function HeroMetric({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
hint: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/6 px-4 py-4 backdrop-blur">
|
||||
<p className="text-[11px] tracking-[0.26em] text-slate-300 uppercase">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-3 text-2xl font-semibold text-white">{value}</p>
|
||||
<p className="mt-2 text-sm text-slate-300">{hint}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CountrySignal({
|
||||
item,
|
||||
index,
|
||||
}: {
|
||||
item: DistributionItem;
|
||||
index: number;
|
||||
}) {
|
||||
const accents = [
|
||||
'from-sky-400/35 to-cyan-400/10',
|
||||
'from-violet-400/35 to-fuchsia-400/10',
|
||||
'from-emerald-400/35 to-teal-400/10',
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-2xl border border-white/10 bg-gradient-to-br px-4 py-3 backdrop-blur ${accents[index % accents.length]}`}
|
||||
>
|
||||
<p className="text-[11px] tracking-[0.24em] text-slate-200 uppercase">
|
||||
{item.key}
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-semibold text-white">
|
||||
{item.value.toLocaleString('zh-CN')}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-300">最近 24 小时来源信号</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorldStage({
|
||||
generatedAt,
|
||||
summary,
|
||||
risk,
|
||||
config,
|
||||
nodes,
|
||||
sourceCountries,
|
||||
}: {
|
||||
generatedAt: string;
|
||||
summary: DashboardSummary;
|
||||
risk: DashboardRiskSummary;
|
||||
config: DashboardConfig;
|
||||
nodes: DashboardNodeHealth[];
|
||||
sourceCountries: DistributionItem[];
|
||||
}) {
|
||||
const onlineRate =
|
||||
summary.total_nodes > 0
|
||||
? (summary.online_nodes / summary.total_nodes) * 100
|
||||
: 0;
|
||||
const syncedNodes = Math.max(
|
||||
0,
|
||||
summary.total_nodes - summary.lagging_nodes - summary.pending_nodes,
|
||||
);
|
||||
const syncRate =
|
||||
summary.total_nodes > 0 ? (syncedNodes / summary.total_nodes) * 100 : 0;
|
||||
const healthyNodes = Math.max(
|
||||
0,
|
||||
summary.online_nodes - summary.unhealthy_nodes - risk.offline_nodes,
|
||||
);
|
||||
const healthyRate =
|
||||
summary.total_nodes > 0 ? (healthyNodes / summary.total_nodes) * 100 : 0;
|
||||
const geoConfiguredNodes = nodes.filter(
|
||||
(node) =>
|
||||
typeof node.geo_latitude === 'number' &&
|
||||
typeof node.geo_longitude === 'number',
|
||||
).length;
|
||||
const signalNodes = nodes.slice(0, Math.max(stageAnchors.length, 8));
|
||||
|
||||
return (
|
||||
<section className="overflow-hidden rounded-[32px] border border-slate-800/70 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.18),transparent_28%),radial-gradient(circle_at_82%_18%,rgba(56,189,248,0.10),transparent_18%),linear-gradient(135deg,#08111f,#0f172a_45%,#111827)] shadow-[0_32px_80px_rgba(2,6,23,0.35)]">
|
||||
<div className="border-b border-white/8 px-6 py-5 md:px-7">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] tracking-[0.34em] text-sky-200/80 uppercase">
|
||||
Global Stage
|
||||
</p>
|
||||
<h2 className="text-2xl font-semibold text-white">全球态势板</h2>
|
||||
<p className="max-w-3xl text-sm leading-6 text-slate-300">
|
||||
先把节点健康、配置追平、活动风险和全球来源信号拉到同一张首屏,
|
||||
让总览页真正承担值守入口的职责。
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-full border border-white/10 bg-white/6 px-4 py-2 text-sm text-slate-200 backdrop-blur">
|
||||
数据生成于 {formatDateTime(generatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 px-6 py-6 md:px-7 xl:grid-cols-[1.4fr_0.8fr]">
|
||||
<div className="space-y-4">
|
||||
<div className="relative min-h-[360px] overflow-hidden rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.16),rgba(15,23,42,0.42))]">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(148,163,184,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(148,163,184,0.08)_1px,transparent_1px)] bg-[size:40px_40px] opacity-35" />
|
||||
<div className="absolute left-8 top-8 rounded-full bg-sky-400/20 px-3 py-1 text-[11px] tracking-[0.22em] text-sky-100 uppercase backdrop-blur">
|
||||
{geoConfiguredNodes > 0 ? '真实节点点位' : '节点信号覆盖'}
|
||||
</div>
|
||||
|
||||
<svg
|
||||
viewBox="0 0 1000 420"
|
||||
className="absolute inset-0 h-full w-full opacity-80"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g fill="none" stroke="rgba(148,163,184,0.18)" strokeWidth="1.5">
|
||||
<path d="M86 98C118 84 168 80 206 92C244 104 270 132 262 154C254 174 210 182 178 180C146 178 108 164 94 142C80 120 76 106 86 98Z" />
|
||||
<path d="M246 210C270 220 286 248 282 276C278 304 266 332 252 360C236 352 228 328 220 306C212 282 210 252 220 232C226 220 236 212 246 210Z" />
|
||||
<path d="M432 86C456 72 510 70 538 84C566 98 566 122 544 132C524 140 488 138 460 130C436 122 418 100 432 86Z" />
|
||||
<path d="M494 154C524 150 552 164 568 188C586 214 592 246 582 274C572 302 546 326 518 326C494 326 474 302 468 276C460 244 458 212 466 186C472 168 480 158 494 154Z" />
|
||||
<path d="M574 84C612 64 674 64 724 78C774 92 832 116 860 152C886 186 880 226 846 244C814 262 752 256 704 248C650 238 598 218 574 186C554 158 548 100 574 84Z" />
|
||||
<path d="M804 298C824 288 858 290 882 302C904 312 912 332 900 346C888 360 856 364 830 360C806 356 788 338 790 320C792 310 796 302 804 298Z" />
|
||||
</g>
|
||||
<g stroke="rgba(56,189,248,0.22)" strokeDasharray="6 8">
|
||||
<path d="M160 150C274 108 414 102 516 146C652 206 742 170 854 182" />
|
||||
<path d="M248 286C358 226 430 206 544 208C668 210 760 256 858 322" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{signalNodes.map((node, index) => {
|
||||
const anchor = projectNodeCoordinates(node, index);
|
||||
const tone = getNodeSignalTone(node);
|
||||
return (
|
||||
<Link
|
||||
key={node.node_id}
|
||||
href={buildNodeDetailHref(node.id)}
|
||||
className={`absolute w-[184px] -translate-x-1/2 -translate-y-1/2 rounded-2xl border px-4 py-3 backdrop-blur transition hover:scale-[1.02] ${tone.ring}`}
|
||||
style={anchor}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<span
|
||||
className={`absolute inset-0 rounded-full blur-md ${tone.halo}`}
|
||||
/>
|
||||
<span
|
||||
className={`relative block h-3.5 w-3.5 rounded-full ${tone.dot}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-white">
|
||||
{node.name}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-300">
|
||||
{(node.geo_name || node.name) + ' · '}
|
||||
请求 {node.request_count.toLocaleString('zh-CN')} · 异常{' '}
|
||||
{node.active_event_count}
|
||||
</p>
|
||||
{!anchor.derivedFromGeo ? (
|
||||
<p className="mt-1 text-[11px] text-slate-400">
|
||||
未配置经纬度,当前使用备用落点
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="absolute bottom-4 left-4 right-4 grid gap-3 md:grid-cols-3">
|
||||
{sourceCountries.length > 0 ? (
|
||||
sourceCountries.slice(0, 3).map((item, index) => (
|
||||
<CountrySignal
|
||||
key={`${item.key}-${item.value}`}
|
||||
item={item}
|
||||
index={index}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-white/15 bg-white/5 px-4 py-4 text-sm text-slate-300 md:col-span-3">
|
||||
当前还没有可用于全球分布展示的来源国家数据。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/5 px-4 py-3 text-xs leading-6 text-slate-300 backdrop-blur">
|
||||
当前已有 {geoConfiguredNodes}/{summary.total_nodes} 个节点配置了真实地图坐标。
|
||||
未配置坐标的节点仍会使用备用落点,不影响首页态势判断。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
|
||||
<HeroMetric
|
||||
label="在线覆盖"
|
||||
value={formatPercent(onlineRate)}
|
||||
hint={`${summary.online_nodes}/${summary.total_nodes} 节点在线`}
|
||||
/>
|
||||
<HeroMetric
|
||||
label="运行健康"
|
||||
value={formatPercent(healthyRate)}
|
||||
hint={`${summary.unhealthy_nodes} 个 OpenResty 不健康`}
|
||||
/>
|
||||
<HeroMetric
|
||||
label="配置追平"
|
||||
value={formatPercent(syncRate)}
|
||||
hint={`${summary.lagging_nodes} 个节点未追平 ${config.active_version || '当前激活版本'}`}
|
||||
/>
|
||||
<HeroMetric
|
||||
label="活动风险"
|
||||
value={summary.active_alerts.toLocaleString('zh-CN')}
|
||||
hint={`${risk.critical_alerts} Critical · ${risk.warning_alerts} Warning`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/6 px-5 py-5 backdrop-blur">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] tracking-[0.24em] text-slate-300 uppercase">
|
||||
节点健康清单
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-semibold text-white">
|
||||
风险优先队列
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/node"
|
||||
className="rounded-full border border-white/12 px-3 py-1.5 text-xs text-slate-100 transition hover:bg-white/8"
|
||||
>
|
||||
查看全部节点
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{nodes.slice(0, 4).map((node) => (
|
||||
<Link
|
||||
key={node.node_id}
|
||||
href={buildNodeDetailHref(node.id)}
|
||||
className="block rounded-2xl border border-white/8 bg-slate-950/20 px-4 py-4 transition hover:border-white/18 hover:bg-white/6"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">
|
||||
{node.name}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
请求 {node.request_count.toLocaleString('zh-CN')} · 错误{' '}
|
||||
{node.error_count.toLocaleString('zh-CN')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<StatusBadge
|
||||
label={getNodeStatusLabel(node.status)}
|
||||
variant={getNodeStatusVariant(node.status)}
|
||||
/>
|
||||
<StatusBadge
|
||||
label={getOpenrestyStatusLabel(node.openresty_status)}
|
||||
variant={getOpenrestyStatusVariant(
|
||||
node.openresty_status,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{nodes.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-white/12 bg-slate-950/20 px-4 py-5 text-sm text-slate-300">
|
||||
当前没有节点健康数据,等节点开始 heartbeat 后这里会出现世界覆盖信号。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -118,6 +118,9 @@ export interface DashboardNodeHealth {
|
||||
id: number;
|
||||
node_id: string;
|
||||
name: string;
|
||||
geo_name: string;
|
||||
geo_latitude?: number | null;
|
||||
geo_longitude?: number | null;
|
||||
status: 'online' | 'offline' | 'pending';
|
||||
openresty_status: 'healthy' | 'unhealthy' | 'unknown';
|
||||
current_version: string;
|
||||
|
||||
@@ -89,10 +89,20 @@ function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
function toPayload(values: NodeFormValues): NodeMutationPayload {
|
||||
function toPayload(
|
||||
values: NodeFormValues,
|
||||
currentNode?: {
|
||||
geo_name?: string;
|
||||
geo_latitude?: number | null;
|
||||
geo_longitude?: number | null;
|
||||
} | null,
|
||||
): NodeMutationPayload {
|
||||
return {
|
||||
name: values.name.trim(),
|
||||
auto_update_enabled: values.auto_update_enabled,
|
||||
geo_name: currentNode?.geo_name ?? '',
|
||||
geo_latitude: currentNode?.geo_latitude ?? null,
|
||||
geo_longitude: currentNode?.geo_longitude ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -345,7 +355,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (values: NodeFormValues) =>
|
||||
updateNode(Number(nodeId), toPayload(values)),
|
||||
updateNode(Number(nodeId), toPayload(values, node)),
|
||||
onSuccess: async (updatedNode) => {
|
||||
setFeedback({ tone: 'success', message: '节点已更新。' });
|
||||
setIsEditorOpen(false);
|
||||
|
||||
@@ -60,6 +60,48 @@ const nodeSchema = z.object({
|
||||
.min(1, '请输入节点名')
|
||||
.max(128, '节点名不能超过 128 个字符'),
|
||||
auto_update_enabled: z.boolean(),
|
||||
geo_name: z.string().trim().max(128, '位置名不能超过 128 个字符'),
|
||||
geo_latitude: z.string().trim(),
|
||||
geo_longitude: z.string().trim(),
|
||||
}).superRefine((values, ctx) => {
|
||||
const hasLatitude = values.geo_latitude !== '';
|
||||
const hasLongitude = values.geo_longitude !== '';
|
||||
|
||||
if (hasLatitude !== hasLongitude) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['geo_latitude'],
|
||||
message: '纬度和经度需要同时填写',
|
||||
});
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['geo_longitude'],
|
||||
message: '纬度和经度需要同时填写',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasLatitude) {
|
||||
const latitude = Number(values.geo_latitude);
|
||||
if (Number.isNaN(latitude) || latitude < -90 || latitude > 90) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['geo_latitude'],
|
||||
message: '纬度必须在 -90 到 90 之间',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (hasLongitude) {
|
||||
const longitude = Number(values.geo_longitude);
|
||||
if (Number.isNaN(longitude) || longitude < -180 || longitude > 180) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['geo_longitude'],
|
||||
message: '经度必须在 -180 到 180 之间',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type NodeFormValues = z.infer<typeof nodeSchema>;
|
||||
@@ -72,6 +114,9 @@ type FeedbackState = {
|
||||
const defaultValues: NodeFormValues = {
|
||||
name: '',
|
||||
auto_update_enabled: false,
|
||||
geo_name: '',
|
||||
geo_latitude: '',
|
||||
geo_longitude: '',
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
@@ -82,6 +127,11 @@ function toPayload(values: NodeFormValues): NodeMutationPayload {
|
||||
return {
|
||||
name: values.name.trim(),
|
||||
auto_update_enabled: values.auto_update_enabled,
|
||||
geo_name: values.geo_name.trim(),
|
||||
geo_latitude:
|
||||
values.geo_latitude.trim() === '' ? null : Number(values.geo_latitude),
|
||||
geo_longitude:
|
||||
values.geo_longitude.trim() === '' ? null : Number(values.geo_longitude),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -213,12 +263,24 @@ export function NodesPage() {
|
||||
nodeId: number,
|
||||
name: string,
|
||||
autoUpdateEnabled: boolean,
|
||||
geoName: string,
|
||||
geoLatitude?: number | null,
|
||||
geoLongitude?: number | null,
|
||||
) => {
|
||||
setFeedback(null);
|
||||
setEditingNodeId(nodeId);
|
||||
form.reset({
|
||||
name,
|
||||
auto_update_enabled: autoUpdateEnabled,
|
||||
geo_name: geoName,
|
||||
geo_latitude:
|
||||
geoLatitude === undefined || geoLatitude === null
|
||||
? ''
|
||||
: String(geoLatitude),
|
||||
geo_longitude:
|
||||
geoLongitude === undefined || geoLongitude === null
|
||||
? ''
|
||||
: String(geoLongitude),
|
||||
});
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
@@ -368,6 +430,9 @@ export function NodesPage() {
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
IP:{node.ip || 'null'}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
位置:{node.geo_name || '未配置地图点位'}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
@@ -427,6 +492,9 @@ export function NodesPage() {
|
||||
node.id,
|
||||
node.name,
|
||||
node.auto_update_enabled,
|
||||
node.geo_name,
|
||||
node.geo_latitude,
|
||||
node.geo_longitude,
|
||||
)
|
||||
}
|
||||
className="px-3 py-2 text-xs"
|
||||
@@ -507,6 +575,41 @@ export function NodesPage() {
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<ResourceField
|
||||
label="地图位置名"
|
||||
hint="示例:Shanghai / Tokyo / Frankfurt,可用于总览世界板标注。"
|
||||
error={form.formState.errors.geo_name?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="Shanghai"
|
||||
{...form.register('geo_name')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<ResourceField
|
||||
label="纬度"
|
||||
hint="范围 -90 到 90,例如上海约为 31.2304"
|
||||
error={form.formState.errors.geo_latitude?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="31.2304"
|
||||
{...form.register('geo_latitude')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="经度"
|
||||
hint="范围 -180 到 180,例如上海约为 121.4737"
|
||||
error={form.formState.errors.geo_longitude?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="121.4737"
|
||||
{...form.register('geo_longitude')}
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
</form>
|
||||
</AppModal>
|
||||
</>
|
||||
|
||||
@@ -5,6 +5,9 @@ export interface NodeItem {
|
||||
node_id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
geo_name: string;
|
||||
geo_latitude?: number | null;
|
||||
geo_longitude?: number | null;
|
||||
agent_token: string;
|
||||
auto_update_enabled: boolean;
|
||||
update_requested: boolean;
|
||||
@@ -37,6 +40,9 @@ export interface NodeBootstrapToken {
|
||||
export interface NodeMutationPayload {
|
||||
name: string;
|
||||
auto_update_enabled: boolean;
|
||||
geo_name: string;
|
||||
geo_latitude?: number | null;
|
||||
geo_longitude?: number | null;
|
||||
}
|
||||
|
||||
export interface NodeAgentReleaseInfo {
|
||||
|
||||
Reference in New Issue
Block a user