问题: i18n 批量转换 (commit55f216c) 生成了 813 个 hash 键引用但翻译从未写入 JSON, 导致用户在受影响模块看到 hash 键路径(如 monitor.quickquerybarts.s64559524)而非实际文本. 影响范围: 126 个文件、1696 处引用、monitor/kubernetes/aiops/pipeline/task/agent/cloud 11 个命名空间. 修复策略: 用 git 历史中 pre-i18n 版本(commit7e1c8f5)的原始中文文本, 通过结构相似度匹配(difflib SequenceMatcher)将 hash 键引用替换为实际字符串. 对于组件拆分后产生的新文件,映射到其原始源文件查找中文文本. 修复明细: - monitor 命名空间: 335 键修复(站点拨测/日志中心/中间件监控/告警规则/SNMP/主机监控) - kubernetes 命名空间: 195 键修复(资源编辑器/Deployment编辑器/事件/命名空间/Pod日志) - aiops 命名空间: 70 键修复(容量预测/成本预测/告警分析) - pipeline/task/agent/cloud/common 命名空间: 其余键修复 验证: tsc -b=0, npm build✓ (16.87s), ESLint=0/0/0, go build=0, 17/17 测试通过, Docker healthy
229 lines
10 KiB
TypeScript
229 lines
10 KiB
TypeScript
// 中间件实例可观测面板 — 批量拉取 + 健康度 + 仪表盘 // Logan.Li
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import {
|
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { Activity, LineChart, Loader2, RefreshCw } from 'lucide-react';
|
|
import { middlewareMonitorApi } from '@/api';
|
|
import type { MwChartDef, MwTarget, MwTimeRange } from './helpers';
|
|
import {
|
|
MW_TIME_RANGE_HOURS, fmtMetricDisplay, fmtMemory, fmtHitRate, fmtVal, fmtLastSeen,
|
|
mysqlConnPct, redisMemoryPct, redisModeLabel, groupHistoryByMetric, counterToDelta,
|
|
evalMwHealth, gaugeTone, gaugeToneLow, REDIS_PRIMARY_KEYS, MYSQL_PRIMARY_KEYS,
|
|
} from './helpers';
|
|
import { MwTrendChart } from './MwTrendChart';
|
|
import { MwGaugeBar } from './MwGaugeBar';
|
|
import { MwHealthStrip } from './MwHealthStrip';
|
|
import { ChartExpandShell } from '../shared/ChartExpandShell';
|
|
import i18n from '@/i18n';
|
|
|
|
interface Props {
|
|
target: MwTarget | null;
|
|
chartDefs: MwChartDef[];
|
|
accent: 'redis' | 'mysql';
|
|
}
|
|
|
|
export function MwTargetChartsPanel({ target, chartDefs, accent }: Props) {
|
|
const [timeRange, setTimeRange] = useState<MwTimeRange>('6h');
|
|
const [historyMap, setHistoryMap] = useState<Record<string, Array<{ timestamp: number; value: number }>>>({});
|
|
const [loading, setLoading] = useState(false);
|
|
const [showAll, setShowAll] = useState(false);
|
|
|
|
const primaryKeys = accent === 'redis' ? REDIS_PRIMARY_KEYS : MYSQL_PRIMARY_KEYS;
|
|
const visibleDefs = useMemo(
|
|
() => (showAll ? chartDefs : chartDefs.filter((d) => primaryKeys.includes(d.key))),
|
|
[showAll, chartDefs, primaryKeys],
|
|
);
|
|
|
|
const loadCharts = useCallback(async () => {
|
|
if (!target) return;
|
|
setLoading(true);
|
|
try {
|
|
const res = await middlewareMonitorApi.getMetrics(target.id, {
|
|
hours: MW_TIME_RANGE_HOURS[timeRange],
|
|
}) as { list?: Array<{ metric_name: string; timestamp: number; value: number }> };
|
|
setHistoryMap(groupHistoryByMetric(res?.list || []));
|
|
} catch {
|
|
setHistoryMap({});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [target, timeRange]);
|
|
|
|
useEffect(() => { loadCharts(); }, [loadCharts]);
|
|
|
|
// 30s 静默刷新趋势 // Logan.Li
|
|
useEffect(() => {
|
|
if (!target) return;
|
|
const timer = setInterval(loadCharts, 30_000);
|
|
return () => clearInterval(timer);
|
|
}, [target, loadCharts]);
|
|
|
|
if (!target) {
|
|
return (
|
|
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-full">
|
|
<CardContent className="p-8 text-center text-sm text-[var(--dg-text-muted)]">
|
|
选择左侧实例查看趋势与健康度
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const m = target.latest_metrics || {};
|
|
const memPct = redisMemoryPct(m);
|
|
const connPct = mysqlConnPct(m);
|
|
const healthIssues = evalMwHealth(target);
|
|
|
|
return (
|
|
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-full">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between gap-2 mb-3 flex-wrap">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<Activity className="h-4 w-4 text-[var(--dg-text-muted)] shrink-0" />
|
|
<h3 className="text-sm font-medium truncate">可观测 · {target.name}</h3>
|
|
<Badge variant="outline" className="text-[10px] shrink-0">{target.ip}:{target.port}</Badge>
|
|
{accent === 'redis' && (
|
|
<Badge variant="outline" className="text-[10px] shrink-0">{redisModeLabel(target.config_public?.redis_mode)}</Badge>
|
|
)}
|
|
<span className="text-[10px] text-[var(--dg-text-muted)] shrink-0">更新 {fmtLastSeen(target.last_seen_at)}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => setShowAll((v) => !v)}>
|
|
{showAll ? i18n.t('核心指标') : i18n.t('全部指标')}
|
|
</Button>
|
|
<Select value={timeRange} onValueChange={(v) => setTimeRange(v as MwTimeRange)}>
|
|
<SelectTrigger className="h-7 w-[92px] text-xs"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="1h"> {i18n.t('monitor.helpersts.sf972b7b1')} </SelectItem>
|
|
<SelectItem value="6h"> {i18n.t('monitor.siteProtocol.rangeDefault')} </SelectItem>
|
|
<SelectItem value="24h"> {i18n.t('monitor.helpersts.s6e99b786')} </SelectItem>
|
|
<SelectItem value="7d"> {i18n.t('monitor.helpersts.sd765efc4')} </SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={loadCharts} disabled={loading}>
|
|
<RefreshCw className={`h-3 w-3 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mb-4"><MwHealthStrip issues={healthIssues} /></div>
|
|
|
|
{/* 仪表盘 */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-4">
|
|
{accent === 'redis' && (
|
|
<>
|
|
<MwGaugeBar
|
|
label={i18n.t('命中率')}
|
|
value={(m.redis_hit_rate || 0) * 100}
|
|
display={fmtHitRate(m.redis_hit_rate)}
|
|
level={m.redis_hit_rate > 0 ? gaugeToneLow((m.redis_hit_rate || 0) * 100, 90, 80) : 'ok'}
|
|
/>
|
|
<MwGaugeBar
|
|
label={i18n.t('命中率')}
|
|
value={memPct ?? (m.redis_used_memory > 0 ? 50 : 0)}
|
|
display={fmtMemory(m.redis_used_memory)}
|
|
level={memPct != null ? gaugeTone(memPct, 80, 90) : 'ok'}
|
|
hint={memPct != null ? `已用 ${memPct.toFixed(0)}%` : i18n.t('未配置 maxmemory')}
|
|
/>
|
|
<MwGaugeBar
|
|
label={i18n.t('kubernetes.topology.connections')}
|
|
value={Math.min(100, (m.redis_connected_clients || 0) / 2)}
|
|
display={fmtVal(m.redis_connected_clients)}
|
|
level={(m.redis_blocked_clients || 0) > 0 ? 'warn' : 'ok'}
|
|
hint={m.redis_cluster_known_nodes ? `集群 ${fmtVal(m.redis_cluster_known_nodes)} 节点` : undefined}
|
|
/>
|
|
</>
|
|
)}
|
|
{accent === 'mysql' && (
|
|
<>
|
|
<MwGaugeBar
|
|
label={i18n.t('命中率')}
|
|
value={(m.mysql_buffer_hit_rate || 0) * 100}
|
|
display={fmtHitRate(m.mysql_buffer_hit_rate)}
|
|
level={m.mysql_buffer_hit_rate > 0 ? gaugeToneLow((m.mysql_buffer_hit_rate || 0) * 100, 95, 90) : 'ok'}
|
|
/>
|
|
<MwGaugeBar
|
|
label={i18n.t('命中率')}
|
|
value={connPct ?? 0}
|
|
display={`${fmtVal(m.mysql_connections)} / ${fmtVal(m.mysql_max_connections)}`}
|
|
level={connPct != null ? gaugeTone(connPct, 80, 90) : 'ok'}
|
|
/>
|
|
<MwGaugeBar
|
|
label={i18n.t('命中率')}
|
|
value={Math.min(100, (m.mysql_threads_running || 0) * 5)}
|
|
display={fmtVal(m.mysql_threads_running)}
|
|
level={(m.mysql_threads_running || 0) > 20 ? 'warn' : 'ok'}
|
|
hint={`慢查询 ${fmtVal(m.mysql_slow_queries)}`}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* 核心摘要 */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-4">
|
|
{accent === 'redis' ? (
|
|
<>
|
|
<SummaryTile label="QPS" value={fmtVal(m.redis_qps, 1)} />
|
|
<SummaryTile label={i18n.t('碎片率')} value={fmtVal(m.redis_db_size)} />
|
|
<SummaryTile label={i18n.t('碎片率')} value={fmtVal(m.redis_evicted_keys)} warn={(m.redis_evicted_keys || 0) > 0} />
|
|
<SummaryTile label={i18n.t('逐出 Key')} value={m.redis_mem_fragmentation_ratio ? fmtVal(m.redis_mem_fragmentation_ratio, 2) : '--'} />
|
|
</>
|
|
) : (
|
|
<>
|
|
<SummaryTile label="QPS" value={fmtVal(m.mysql_qps, 1)} />
|
|
<SummaryTile label={i18n.t('发送流量')} value={fmtMemory(m.mysql_bytes_received)} />
|
|
<SummaryTile label={i18n.t('发送流量')} value={fmtMemory(m.mysql_bytes_sent)} />
|
|
<SummaryTile label={i18n.t('agent.agentUptime')} value={m.mysql_uptime ? `${Math.floor(m.mysql_uptime / 3600)}h` : '--'} />
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="py-10 flex items-center justify-center text-sm text-[var(--dg-text-muted)]">
|
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />加载趋势...
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
{visibleDefs.map((def) => {
|
|
const series = def.counterDelta ? counterToDelta(historyMap[def.key] || []) : (historyMap[def.key] || []);
|
|
return (
|
|
<ChartExpandShell
|
|
key={def.key}
|
|
className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] p-3"
|
|
title={`${def.label} · ${fmtMetricDisplay(def, m[def.key] ?? 0)}`}
|
|
icon={<LineChart className="h-3.5 w-3.5 text-[var(--dg-text-muted)] shrink-0" />}
|
|
compactHeight={150}
|
|
expandedHeight={400}
|
|
empty={series.length === 0}
|
|
>
|
|
{(h) => (
|
|
<MwTrendChart
|
|
data={series}
|
|
color={def.color}
|
|
unit={def.unit}
|
|
percent={def.percent}
|
|
formatter={def.formatter}
|
|
height={h}
|
|
/>
|
|
)}
|
|
</ChartExpandShell>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function SummaryTile({ label, value, warn }: { label: string; value: string; warn?: boolean }) {
|
|
return (
|
|
<div className={`rounded-lg p-3 text-center ${warn ? 'bg-amber-500/10' : 'bg-[var(--dg-bg-tertiary)]'}`}>
|
|
<p className="text-[10px] text-[var(--dg-text-muted)]">{label}</p>
|
|
<p className={`text-sm font-mono font-semibold mt-0.5 ${warn ? 'dg-text-yellow' : ''}`}>{value}</p>
|
|
</div>
|
|
);
|
|
} |