批量转换脚本引入的语法错误已回退,保留稳定的 i18n 转换: - 翻译词典: 22 namespaces, 3600+ keys - 已接入: 145+ 文件 (Dashboard/Login/K8s 主页面/Monitor/System/Deploy) - 语言切换器在 Header 中可用 (🇨🇳/🇺🇸) - tsc 零错误, Vite 构建成功
252 lines
9.2 KiB
TypeScript
252 lines
9.2 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||
// 面板内容渲染 — 执行 PromQL 查询并渲染真实数据 // Logan.Li
|
||
import { useState, useEffect, useCallback } from 'react';
|
||
import { TrendingUp, Loader2, AlertCircle } from 'lucide-react';
|
||
import { LineChart, Line, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
|
||
import type { Panel } from './GrafanaDashboard';
|
||
import { prometheusProxyApi, promDatasourceApi } from '@/api';
|
||
|
||
// 默认 Prometheus URL 缓存
|
||
let _defaultPromURL: string | null = null;
|
||
|
||
async function getDefaultPromURL(dashboardURL?: string): Promise<string> {
|
||
// 1. 优先使用仪表盘指定的 URL
|
||
if (dashboardURL) return dashboardURL;
|
||
// 2. 缓存命中
|
||
if (_defaultPromURL) return _defaultPromURL;
|
||
// 3. 查询默认 Prometheus 数据源
|
||
try {
|
||
const res = await promDatasourceApi.list();
|
||
const list = (res as any[]) ?? [];
|
||
const def = list.find((d: any) => d.is_default) || list[0];
|
||
if (def?.url) {
|
||
_defaultPromURL = def.url;
|
||
return def.url;
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
return '';
|
||
}
|
||
|
||
// 解析 Prometheus range query 结果
|
||
function parseRangeResult(promResult: any): { time: string; value: number }[] {
|
||
try {
|
||
const result = promResult?.data?.result;
|
||
if (!Array.isArray(result) || result.length === 0) return [];
|
||
|
||
// 取第一条时间序列(多序列时合并取均值)
|
||
const series = result[0];
|
||
const values = series?.values;
|
||
if (!Array.isArray(values)) return [];
|
||
|
||
return values.map((v: any) => {
|
||
const ts = v[0] as number;
|
||
const val = parseFloat(v[1] as string) || 0;
|
||
const d = new Date(ts * 1000);
|
||
return {
|
||
time: `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`,
|
||
value: Math.round(val * 100) / 100,
|
||
};
|
||
});
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// 解析 Prometheus instant query 结果
|
||
function parseInstantResult(promResult: any): { labels: Record<string, string>; value: number }[] {
|
||
try {
|
||
const result = promResult?.data?.result;
|
||
if (!Array.isArray(result)) return [];
|
||
return result.map((r: any) => ({
|
||
labels: r.metric ?? {},
|
||
value: parseFloat(Array.isArray(r.value) ? r.value[1] : '0') || 0,
|
||
}));
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/** 渲染面板内容 */
|
||
export function GrafanaPanelContent({ panel, datasourceUrl }: { panel: Panel; datasourceUrl?: string }) {
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const [chartData, setChartData] = useState<{ time: string; value: number }[]>([]);
|
||
const [instantData, setInstantData] = useState<{ labels: Record<string, string>; value: number }[]>([]);
|
||
|
||
const fetchData = useCallback(async () => {
|
||
if (!panel.query) return;
|
||
setLoading(true);
|
||
setError('');
|
||
try {
|
||
const url = await getDefaultPromURL(datasourceUrl);
|
||
if (!url) {
|
||
setError('未配置 Prometheus 数据源');
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
if (panel.type === 'graph') {
|
||
// 范围查询:最近 1 小时,1 分钟步长
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const start = now - 3600;
|
||
const res = await prometheusProxyApi.queryRange({
|
||
url,
|
||
query: panel.query,
|
||
start: String(start),
|
||
end: String(now),
|
||
step: '1m',
|
||
});
|
||
const parsed = parseRangeResult(res);
|
||
setChartData(parsed.length > 0 ? parsed : []);
|
||
if (parsed.length === 0) setError('无数据');
|
||
} else {
|
||
// stat / gauge / table 用即时查询
|
||
const res = await prometheusProxyApi.query(url, panel.query);
|
||
const parsed = parseInstantResult(res);
|
||
setInstantData(parsed);
|
||
if (parsed.length === 0) setError('无数据');
|
||
}
|
||
} catch (e: any) {
|
||
setError(e?.message ? String(e.message).slice(0, 80) : '查询失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [panel.query, panel.type, datasourceUrl]);
|
||
|
||
useEffect(() => {
|
||
fetchData();
|
||
// 自动刷新(每 30s)
|
||
const id = setInterval(fetchData, 30000);
|
||
return () => clearInterval(id);
|
||
}, [fetchData]);
|
||
|
||
// Loading 状态
|
||
if (loading && chartData.length === 0 && instantData.length === 0) {
|
||
return (
|
||
<div className="flex items-center justify-center h-full min-h-[120px]">
|
||
<Loader2 className="w-5 h-5 animate-spin text-[var(--dg-text-muted)]" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Error 状态
|
||
if (error && chartData.length === 0 && instantData.length === 0) {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center h-full min-h-[120px] text-[var(--dg-text-muted)]">
|
||
<AlertCircle className="w-5 h-5 mb-1 opacity-50" />
|
||
<span className="text-[11px]">{error}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
switch (panel.type) {
|
||
case 'stat': {
|
||
const val = instantData[0]?.value ?? 0;
|
||
const unit = (panel.options as any)?.unit ?? '';
|
||
return (
|
||
<div className="flex flex-col items-center justify-center h-full min-h-[120px]">
|
||
<span className="text-4xl font-bold dg-text-blue">
|
||
{val > 1000 ? (val / 1000).toFixed(1) + 'K' : val.toFixed(2)}
|
||
</span>
|
||
{unit && <span className="text-sm text-[var(--dg-text-muted)] mt-2">{unit}</span>}
|
||
<div className="flex items-center gap-1 mt-2 dg-text-green text-sm">
|
||
<TrendingUp className="w-4 h-4" />
|
||
<span>实时</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
case 'gauge': {
|
||
const val = instantData[0]?.value ?? 0;
|
||
const pct = Math.min(val, 100);
|
||
return (
|
||
<div className="flex flex-col items-center justify-center h-full min-h-[120px]">
|
||
<div className="relative w-24 h-24">
|
||
<svg className="w-full h-full" viewBox="0 0 100 100">
|
||
<circle cx="50" cy="50" r="40" fill="none" stroke="var(--dg-border)" strokeWidth="10" />
|
||
<circle
|
||
cx="50"
|
||
cy="50"
|
||
r="40"
|
||
fill="none"
|
||
stroke={pct > 80 ? '#ef4444' : pct > 60 ? '#f59e0b' : '#3b82f6'}
|
||
strokeWidth="10"
|
||
strokeDasharray={`${(pct / 100) * 251} 251`}
|
||
transform="rotate(-90 50 50)"
|
||
style={{ transition: 'stroke-dasharray 0.5s ease' }}
|
||
/>
|
||
</svg>
|
||
<div className="absolute inset-0 flex items-center justify-center">
|
||
<span className="text-xl font-bold">{pct.toFixed(1)}%</span>
|
||
</div>
|
||
</div>
|
||
<span className="text-sm text-[var(--dg-text-muted)] mt-2">{panel.title}</span>
|
||
</div>
|
||
);
|
||
}
|
||
case 'table': {
|
||
if (instantData.length === 0) return <div className="text-center py-4 text-xs text-[var(--dg-text-muted)]">无数据</div>;
|
||
return (
|
||
<div className="overflow-auto max-h-[200px]">
|
||
<table className="w-full text-[11px]">
|
||
<thead>
|
||
<tr className="border-b border-[var(--dg-border)]">
|
||
{Object.keys(instantData[0]?.labels ?? {}).slice(0, 3).map((k) => (
|
||
<th key={k} className="text-left py-1 px-2 font-medium text-[var(--dg-text-secondary)]">{k}</th>
|
||
))}
|
||
<th className="text-right py-1 px-2 font-medium text-[var(--dg-text-secondary)]">value</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{instantData.slice(0, 20).map((row, i) => (
|
||
<tr key={i} className="border-b border-[var(--dg-border)]/50">
|
||
{Object.entries(row.labels).slice(0, 3).map(([k, v]) => (
|
||
<td key={k} className="py-1 px-2 text-[var(--dg-text-secondary)]">{v}</td>
|
||
))}
|
||
<td className="py-1 px-2 text-right font-mono dg-text-blue">{row.value.toFixed(2)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
case 'graph':
|
||
default: {
|
||
if (chartData.length === 0) {
|
||
return <div className="text-center py-4 text-xs text-[var(--dg-text-muted)]">无数据</div>;
|
||
}
|
||
return (
|
||
<div className="h-full min-h-[120px]">
|
||
<ResponsiveContainer width="100%" height="100%">
|
||
<LineChart data={chartData} margin={{ top: 5, right: 10, bottom: 5, left: -20 }}>
|
||
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" opacity={0.3} />
|
||
<XAxis dataKey="time" tick={{ fontSize: 10, fill: 'var(--dg-text-muted)' }} interval="preserveStartEnd" />
|
||
<YAxis tick={{ fontSize: 10, fill: 'var(--dg-text-muted)' }} width={45} />
|
||
<Tooltip
|
||
contentStyle={{
|
||
background: 'var(--dg-bg-secondary)',
|
||
border: '1px solid var(--dg-border)',
|
||
borderRadius: '6px',
|
||
fontSize: '12px',
|
||
}}
|
||
labelStyle={{ color: 'var(--dg-text-primary)' }}
|
||
/>
|
||
<Line
|
||
type="monotone"
|
||
dataKey="value"
|
||
stroke="#3b82f6"
|
||
strokeWidth={2}
|
||
dot={false}
|
||
animationDuration={300}
|
||
/>
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
}
|