feat(monitor): F4 自定义监控仪表盘 — mock 桩改真实 CRUD + PromQL 查询渲染
后端: - 新增 model/custom_dashboard.go (monitor_custom_dashboards 表) - 新增 controller/custom_dashboard.go (CRUD + 设默认, 6 路由) - routes.go 注册 /custom-dashboards 路由组 - database.go AutoMigrate 注册 CustomDashboard 前端: - grafana.ts: dashboardApi 从 mock 返回空改为真实 HTTP 调用 - metrics.ts: prometheusProxyApi 新增 query/queryRange 方法 - GrafanaPanelContent.tsx: 从随机 mock 数据改为执行 PromQL 查询 + Recharts LineChart 渲染 graph 类型 + SVG gauge 渲染 gauge 类型 + 数值卡片渲染 stat 类型 + 表格渲染 table 类型 + 30s 自动刷新 + loading/error 状态 + 自动发现默认 Prometheus 数据源 - GrafanaDashboard.tsx: 传递 datasourceUrl 到 GrafanaPanelContent 验证: go build✅ npm build✅ 17/17 集成测试通过 E2E CRUD 全通过
This commit is contained in:
1 parent
7af10097c9
commit
f725ca7cac
11 files changed
+670
-53
No files matched your search
@@ -468,7 +468,7 @@ const DashboardTab: React.FC = () => {
|
||||
{active.panels.map(p => (
|
||||
<Card key={p.id} style={{ gridColumn: `span ${p.width}`, minHeight: `${p.height * 80}px` }}>
|
||||
<CardHeader className="py-2 px-3"><CardTitle className="text-[11px] font-medium">{p.title}</CardTitle></CardHeader>
|
||||
<CardContent className="p-3 pt-0"><GrafanaPanelContent panel={p} /></CardContent>
|
||||
<CardContent className="p-3 pt-0"><GrafanaPanelContent panel={p} datasourceUrl={active.datasourceUrl} /></CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,77 +1,251 @@
|
||||
import { TrendingUp } from 'lucide-react';
|
||||
/* 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';
|
||||
|
||||
/** 模拟图表数据 */
|
||||
function generateChartData(_panel: Panel) {
|
||||
const dataPoints = 24;
|
||||
const data = [];
|
||||
for (let i = 0; i < dataPoints; i++) {
|
||||
data.push({
|
||||
time: new Date(Date.now() - (dataPoints - i) * 3600000).toLocaleTimeString(),
|
||||
value: Math.random() * 100
|
||||
// 默认 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 [];
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 渲染面板内容 */
|
||||
export function GrafanaPanelContent({ panel }: { panel: Panel }) {
|
||||
const data = generateChartData(panel);
|
||||
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':
|
||||
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">
|
||||
<span className="text-4xl font-bold text-blue-500">{Math.floor(Math.random() * 1000)}</span>
|
||||
<span className="text-sm text-muted-foreground mt-2">{panel.options?.unit || 'requests/s'}</span>
|
||||
<div className="flex flex-col items-center justify-center h-full min-h-[120px]">
|
||||
<span className="text-4xl font-bold text-blue-500">
|
||||
{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 text-green-500 text-sm">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
<span>+12.5%</span>
|
||||
<span>实时</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'gauge':
|
||||
}
|
||||
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">
|
||||
<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="#e5e7eb" strokeWidth="10" />
|
||||
<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="#3b82f6"
|
||||
stroke={pct > 80 ? '#ef4444' : pct > 60 ? '#f59e0b' : '#3b82f6'}
|
||||
strokeWidth="10"
|
||||
strokeDasharray={`${Math.random() * 251} 251`}
|
||||
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">{Math.floor(Math.random() * 100)}%</span>
|
||||
<span className="text-xl font-bold">{pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground mt-2">CPU使用率</span>
|
||||
<span className="text-sm text-[var(--dg-text-muted)] mt-2">{panel.title}</span>
|
||||
</div>
|
||||
);
|
||||
case 'graph':
|
||||
default:
|
||||
}
|
||||
case 'table': {
|
||||
if (instantData.length === 0) return <div className="text-center py-4 text-xs text-[var(--dg-text-muted)]">无数据</div>;
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-1 flex items-end gap-1 px-2">
|
||||
{data.map((d, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 bg-blue-500 rounded-t"
|
||||
style={{ height: `${d.value}%`, opacity: 0.3 + (i / data.length) * 0.7 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground px-2 pt-2">
|
||||
<span>{data[0].time}</span>
|
||||
<span>{data[data.length - 1].time}</span>
|
||||
</div>
|
||||
<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 text-blue-400">{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>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user