mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-05 15:26:36 +08:00
feat(dashboard): enhance risk summary and peak node display in dashboard overview
- Added risk summary metrics including critical alerts, high CPU/memory nodes, and lagging nodes to the dashboard overview. - Introduced peak node metrics for busiest and riskiest nodes with detailed display. - Updated types for dashboard overview to include risk and peak summaries. - Implemented new components for displaying risk signals and peak cards. - Enhanced unit tests to cover new risk and peak metrics in the dashboard overview. fix(update): improve error messages for server upgrade processes - Translated and clarified error messages related to server upgrades and binary uploads. - Ensured consistent error handling and messaging throughout the upgrade process. test(update): refactor test utilities for server upgrade - Renamed roundTripFunc to serverUpdateRoundTripFunc for clarity in test code. - Updated test cases to utilize the new naming convention. feat(node-detail): add traffic structure distribution and health event timeline - Implemented traffic breakdown visualization for status codes and top domains in node detail page. - Added health event timeline to display active and resolved events for better observability. feat(rank-chart): create reusable rank chart component - Developed a new RankChart component for visualizing ranked data distributions. - Integrated RankChart into node detail page for displaying status code and domain distributions.
This commit is contained in:
@@ -13,6 +13,7 @@ import { getDashboardOverview } from '@/features/dashboard/api/overview';
|
||||
import type {
|
||||
DashboardAlert,
|
||||
DashboardNodeHealth,
|
||||
DashboardPeakNode,
|
||||
} from '@/features/dashboard/types';
|
||||
import {
|
||||
getNodeStatusLabel,
|
||||
@@ -56,6 +57,84 @@ function getAlertVariant(
|
||||
return 'info';
|
||||
}
|
||||
|
||||
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 RiskSignal({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
hint,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone: 'danger' | 'warning' | 'info' | 'success';
|
||||
hint: string;
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === 'danger'
|
||||
? 'border-rose-400/30 bg-rose-500/10 text-rose-100'
|
||||
: tone === 'warning'
|
||||
? 'border-amber-400/30 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';
|
||||
|
||||
return (
|
||||
<div className={`rounded-3xl border px-4 py-4 ${toneClass}`}>
|
||||
<p className="text-xs tracking-[0.22em] uppercase opacity-75">{label}</p>
|
||||
<p className="mt-3 text-3xl font-semibold">{value}</p>
|
||||
<p className="mt-2 text-sm opacity-80">{hint}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PeakCard({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
hint: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-3 text-xl font-semibold text-[var(--foreground-primary)]">
|
||||
{value}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">{hint}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 OverviewMetric({
|
||||
label,
|
||||
value,
|
||||
@@ -224,6 +303,86 @@ export function DashboardOverview() {
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
|
||||
<AppCard
|
||||
title="风险态势"
|
||||
description="把异常、容量压力和配置落后聚到同一层,方便先确定今天该盯哪里。"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<RiskSignal
|
||||
label="Critical"
|
||||
value={overview.risk.critical_alerts}
|
||||
tone="danger"
|
||||
hint="当前仍在触发中的严重异常"
|
||||
/>
|
||||
<RiskSignal
|
||||
label="Warning"
|
||||
value={overview.risk.warning_alerts}
|
||||
tone="warning"
|
||||
hint="需要尽快介入但尚未到故障级"
|
||||
/>
|
||||
<RiskSignal
|
||||
label="配置落后"
|
||||
value={overview.risk.lagging_nodes}
|
||||
tone="info"
|
||||
hint="当前版本未追平全局激活配置"
|
||||
/>
|
||||
<RiskSignal
|
||||
label="OpenResty 异常"
|
||||
value={overview.risk.unhealthy_nodes}
|
||||
tone="danger"
|
||||
hint="运行态已出现不健康节点"
|
||||
/>
|
||||
<RiskSignal
|
||||
label="高 CPU / 内存"
|
||||
value={
|
||||
overview.risk.high_cpu_nodes + overview.risk.high_memory_nodes
|
||||
}
|
||||
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} 个离线节点`}
|
||||
/>
|
||||
</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}
|
||||
/>
|
||||
<PeakCard
|
||||
label="优先排查节点"
|
||||
value={formatPeakNode(overview.peaks.riskiest_node).title}
|
||||
hint={formatPeakNode(overview.peaks.riskiest_node).hint}
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<AppCard
|
||||
title="24 小时请求趋势"
|
||||
|
||||
@@ -30,6 +30,42 @@ export interface DashboardConfig {
|
||||
pending_nodes: number;
|
||||
}
|
||||
|
||||
export interface DashboardRiskSummary {
|
||||
critical_alerts: number;
|
||||
warning_alerts: number;
|
||||
info_alerts: number;
|
||||
offline_nodes: number;
|
||||
unhealthy_nodes: number;
|
||||
lagging_nodes: number;
|
||||
high_cpu_nodes: number;
|
||||
high_memory_nodes: number;
|
||||
high_storage_nodes: number;
|
||||
}
|
||||
|
||||
export interface DashboardPeakHour {
|
||||
bucket_started_at: string;
|
||||
request_count: number;
|
||||
error_count: number;
|
||||
}
|
||||
|
||||
export interface DashboardPeakNode {
|
||||
node_id: string;
|
||||
node_name: string;
|
||||
request_count: number;
|
||||
error_count: number;
|
||||
cpu_usage_percent: number;
|
||||
active_event_count: number;
|
||||
openresty_status: 'healthy' | 'unhealthy' | 'unknown';
|
||||
storage_usage_percent: number;
|
||||
}
|
||||
|
||||
export interface DashboardPeakSummary {
|
||||
peak_request_hour: DashboardPeakHour;
|
||||
peak_error_hour: DashboardPeakHour;
|
||||
busiest_node: DashboardPeakNode | null;
|
||||
riskiest_node: DashboardPeakNode | null;
|
||||
}
|
||||
|
||||
export interface TrafficTrendPoint {
|
||||
bucket_started_at: string;
|
||||
request_count: number;
|
||||
@@ -82,6 +118,8 @@ export interface DashboardOverview {
|
||||
traffic: DashboardTraffic;
|
||||
capacity: DashboardCapacity;
|
||||
config: DashboardConfig;
|
||||
risk: DashboardRiskSummary;
|
||||
peaks: DashboardPeakSummary;
|
||||
trends: DashboardTrends;
|
||||
nodes: DashboardNodeHealth[];
|
||||
active_alerts: DashboardAlert[];
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
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';
|
||||
@@ -152,6 +153,48 @@ function formatTrendHour(value: string) {
|
||||
return `${date.getHours().toString().padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
function parseTrafficMap(value?: string | null) {
|
||||
if (!value) {
|
||||
return {} as Record<string, number>;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Record<string, number>;
|
||||
return Object.entries(parsed).reduce<Record<string, number>>(
|
||||
(result, [key, count]) => {
|
||||
if (typeof count === 'number' && Number.isFinite(count)) {
|
||||
result[key] = count;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
{},
|
||||
);
|
||||
} catch {
|
||||
return {} as Record<string, number>;
|
||||
}
|
||||
}
|
||||
|
||||
function aggregateTrafficBreakdown(
|
||||
reports: NodeObservability['traffic_reports'],
|
||||
field: 'status_codes_json' | 'top_domains_json',
|
||||
) {
|
||||
const summary = new Map<string, number>();
|
||||
for (const report of reports) {
|
||||
const parsed = parseTrafficMap(report[field]);
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
summary.set(key, (summary.get(key) ?? 0) + value);
|
||||
}
|
||||
}
|
||||
return Array.from(summary.entries())
|
||||
.sort((left, right) => {
|
||||
if (right[1] === left[1]) {
|
||||
return left[0].localeCompare(right[0]);
|
||||
}
|
||||
return right[1] - left[1];
|
||||
})
|
||||
.slice(0, 6)
|
||||
.map(([label, value]) => ({ label, value }));
|
||||
}
|
||||
|
||||
function getHealthEventVariant(
|
||||
event: NodeObservability['health_events'][number],
|
||||
): 'success' | 'warning' | 'danger' | 'info' {
|
||||
@@ -467,6 +510,22 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
const activeHealthEvents =
|
||||
observability?.health_events.filter((event) => event.status === 'active') ??
|
||||
[];
|
||||
const statusCodeDistribution = useMemo(
|
||||
() =>
|
||||
aggregateTrafficBreakdown(
|
||||
observability?.traffic_reports ?? [],
|
||||
'status_codes_json',
|
||||
),
|
||||
[observability?.traffic_reports],
|
||||
);
|
||||
const topDomains = useMemo(
|
||||
() =>
|
||||
aggregateTrafficBreakdown(
|
||||
observability?.traffic_reports ?? [],
|
||||
'top_domains_json',
|
||||
),
|
||||
[observability?.traffic_reports],
|
||||
);
|
||||
const memoryUsageRatio = formatUsageRatio(
|
||||
latestMetricSnapshot?.memory_used_bytes,
|
||||
latestMetricSnapshot?.memory_total_bytes,
|
||||
@@ -908,6 +967,93 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
||||
<AppCard
|
||||
title="请求结构分布"
|
||||
description="聚合最近 24 小时窗口上报,帮助判断错误集中在哪些状态码、流量集中在哪些域名。"
|
||||
>
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<div>
|
||||
<p className="mb-4 text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
状态码分布
|
||||
</p>
|
||||
<RankChart
|
||||
items={statusCodeDistribution}
|
||||
color="#f59e0b"
|
||||
emptyMessage="暂无状态码分布"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-4 text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
Top Domain
|
||||
</p>
|
||||
<RankChart
|
||||
items={topDomains}
|
||||
color="#2563eb"
|
||||
emptyMessage="暂无域名分布"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="健康事件时间线"
|
||||
description="保留活动与已恢复事件,帮助判断问题是持续中、间歇性还是已经恢复。"
|
||||
>
|
||||
{observability?.health_events.length ? (
|
||||
<div className="space-y-4">
|
||||
{observability.health_events.slice(0, 8).map((event) => (
|
||||
<div
|
||||
key={`${event.event_type}-${event.last_triggered_at}-${event.status}`}
|
||||
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge
|
||||
label={getHealthEventLabel(event)}
|
||||
variant={getHealthEventVariant(event)}
|
||||
/>
|
||||
<StatusBadge
|
||||
label={event.status === 'active' ? '活动中' : '已恢复'}
|
||||
variant={
|
||||
event.status === 'active' ? 'warning' : 'success'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-[var(--foreground-secondary)]">
|
||||
{event.message || '暂无详细消息'}
|
||||
</p>
|
||||
<div className="mt-3 grid gap-2 text-xs text-[var(--foreground-muted)] md:grid-cols-3">
|
||||
<p>
|
||||
首次触发:
|
||||
{isMeaningfulTime(event.first_triggered_at)
|
||||
? ` ${formatDateTime(event.first_triggered_at)}`
|
||||
: ' —'}
|
||||
</p>
|
||||
<p>
|
||||
最近触发:
|
||||
{isMeaningfulTime(event.last_triggered_at)
|
||||
? ` ${formatDateTime(event.last_triggered_at)}`
|
||||
: ' —'}
|
||||
</p>
|
||||
<p>
|
||||
恢复时间:
|
||||
{isMeaningfulTime(event.resolved_at)
|
||||
? ` ${formatDateTime(event.resolved_at)}`
|
||||
: ' —'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="暂无健康事件时间线"
|
||||
description="节点当前还没有上报可展示的健康事件记录。"
|
||||
/>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<AppCard title="更新模式">
|
||||
<div className="space-y-3">
|
||||
|
||||
Reference in New Issue
Block a user