feat(rdp): 新增 RDP 远程桌面/SMB/账单同步能力 + 清理编译产物
主要变更: - RDP 远程桌面:新增 backend/pkg/rdp(RDP 引擎 + credssp/display/rle/trace)、 backend/controller/rdp*(连接/凭证/文件/会话/实时)、frontend RDP 组件与 API - Windows SMB 文件传输:backend/pkg/winsmb - 云账单同步:backend/pkg/billingsync + billing_sync/aliyun_billing,前端账单详情 - Agent CLI Kubernetes 工具:kubernetes_action/get、pod_execute/logs - 服务器导入助手:server_import_helpers、ImportCredentialSettings - 国际化与组件重构(cloud/deploy/monitor/server/system 等多模块) - 清理编译产物二进制:移除 backend/server、backend/agent/bin/aiops-agent-* - .gitignore:新增 backend/agent/ 与 .cache/(避免 334MB go-build 缓存误入库) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
777d9e4e71
commit
76ec9387f0
202 files changed
+16555
-2543
No files matched your search
@@ -1,11 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// 监控大屏 — 标签页切换 Agent / Zabbix / Prometheus + 自定义仪表盘 // Logan.Li
|
||||
// 监控大屏 — 标签页切换 Agent / Zabbix / Prometheus // Logan.Li
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
|
||||
Monitor,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
@@ -29,7 +28,6 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
@@ -37,8 +35,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { toast } from 'sonner';
|
||||
import { useUserStore } from '@/store';
|
||||
import { dashboardApi, promDatasourceApi, zabbixDatasourceApi, agentApi, serverApi } from '@/api';
|
||||
import { GrafanaPanelContent } from './GrafanaPanelContent';
|
||||
import { promDatasourceApi, zabbixDatasourceApi, agentApi, serverApi } from '@/api';
|
||||
import { ZabbixDashboard } from './ZabbixDashboard';
|
||||
import { PrometheusDashboard } from './PrometheusDashboard';
|
||||
import { asList } from '@/lib/agent-utils';
|
||||
@@ -52,12 +49,11 @@ import { PageLoading } from '@/components/layout/PageLoading';
|
||||
import { PageEmpty } from '@/components/layout/PageEmpty';
|
||||
|
||||
// ═══════════ Tab 配置 ═══════════
|
||||
type TabId = 'agent' | 'zabbix' | 'prometheus' | 'dashboard';
|
||||
type TabId = 'agent' | 'zabbix' | 'prometheus';
|
||||
const getTabs = (t: (key: string) => string): { id: TabId; label: string; icon: React.ReactNode }[] => [
|
||||
{ id: 'agent', label: t('monitor.dashboard.tab.agent'), icon: <Server className="w-3.5 h-3.5" /> },
|
||||
{ id: 'zabbix', label: 'Zabbix', icon: <Activity className="w-3.5 h-3.5" /> },
|
||||
{ id: 'prometheus', label: 'Prometheus', icon: <Activity className="w-3.5 h-3.5" /> },
|
||||
{ id: 'dashboard', label: t('monitor.dashboard.tab.dashboard'), icon: <Monitor className="w-3.5 h-3.5" /> },
|
||||
];
|
||||
|
||||
// ═══════════ Agent 主机监控表格 ═══════════
|
||||
@@ -426,7 +422,11 @@ const AgentTab: React.FC = () => {
|
||||
<td className="px-2 py-2.5">
|
||||
<Checkbox checked={selected.has(String(h.server_id))} onCheckedChange={(c) => {
|
||||
const next = new Set(selected);
|
||||
c ? next.add(String(h.server_id)) : next.delete(String(h.server_id));
|
||||
if (c) {
|
||||
next.add(String(h.server_id));
|
||||
} else {
|
||||
next.delete(String(h.server_id));
|
||||
}
|
||||
setSelected(next);
|
||||
}} />
|
||||
</td>
|
||||
@@ -480,16 +480,65 @@ const AgentTab: React.FC = () => {
|
||||
};
|
||||
|
||||
// ═══════════ 数据源 Wrapper ═══════════
|
||||
interface MonitorDatasource {
|
||||
id: number | string;
|
||||
name?: string;
|
||||
url: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
const normalizeDatasourceList = (r: any): MonitorDatasource[] => {
|
||||
const list = (r?.data || r || []) as MonitorDatasource[];
|
||||
return [...list].sort((a, b) => {
|
||||
if (!!a.is_default !== !!b.is_default) return a.is_default ? -1 : 1;
|
||||
return String(a.name || a.url).localeCompare(String(b.name || b.url));
|
||||
});
|
||||
};
|
||||
|
||||
const DatasourceToolbar: React.FC<{
|
||||
sources: MonitorDatasource[];
|
||||
activeId: string;
|
||||
onChange: (id: string) => void;
|
||||
onRefresh: () => void;
|
||||
refreshing?: boolean;
|
||||
}> = ({ sources, activeId, onChange, onRefresh, refreshing }) => (
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||
<Select value={activeId} onValueChange={onChange}>
|
||||
<SelectTrigger className="w-[280px] h-8 text-xs dg-input">
|
||||
<SelectValue placeholder="选择数据源" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="dg-input">
|
||||
{sources.map(ds => (
|
||||
<SelectItem key={String(ds.id)} value={String(ds.id)}>
|
||||
{ds.name || ds.url}{ds.is_default ? ' · 默认' : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" className="h-8 text-xs gap-1" onClick={onRefresh} disabled={refreshing}>
|
||||
<RefreshCw className={cn('w-3 h-3', refreshing && 'animate-spin')} />刷新数据源
|
||||
</Button>
|
||||
<span className="text-[11px] text-[var(--dg-text-muted)]">共 {sources.length} 个数据源,当前仅查询选中项</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ZabbixTab: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [sources, setSources] = useState<MonitorDatasource[]>([]);
|
||||
const [ds, setDs] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
zabbixDatasourceApi.list().then(( r: any) => {
|
||||
const list = r?.data || r || [];
|
||||
setDs(list.find(( d: any) => d.is_default) || list[0] || null);
|
||||
}).catch(() => {}).finally(() => setLoading(false));
|
||||
}, []);
|
||||
const fetchSources = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = normalizeDatasourceList(await zabbixDatasourceApi.list());
|
||||
setSources(list);
|
||||
setDs(current => list.find(d => String(d.id) === String(current?.id)) || list[0] || null);
|
||||
} catch { setSources([]); setDs(null); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
useEffect(() => { fetchSources(); }, []);
|
||||
if (loading) return <PageLoading label={t('monitor.dashboard.zabbix.loading')} />;
|
||||
if (!ds) return (
|
||||
<PageEmpty
|
||||
@@ -498,19 +547,29 @@ const ZabbixTab: React.FC = () => {
|
||||
action={<a href="/datasources" className="text-xs dg-text-blue hover:underline inline-flex items-center gap-1">{t('monitor.dashboard.zabbix.goConfig')} <ExternalLink className="w-3 h-3" /></a>}
|
||||
/>
|
||||
);
|
||||
return <ZabbixDashboard dsUrl={ds.url} dsUsername={ds.username} dsPassword={ds.password} />;
|
||||
return (
|
||||
<div>
|
||||
<DatasourceToolbar sources={sources} activeId={String(ds.id)} onChange={(id) => setDs(sources.find(item => String(item.id) === id) || ds)} onRefresh={fetchSources} refreshing={loading} />
|
||||
<ZabbixDashboard key={String(ds.id)} dsUrl={ds.url} dsUsername={ds.username} dsPassword={ds.password} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PrometheusTab: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [sources, setSources] = useState<MonitorDatasource[]>([]);
|
||||
const [ds, setDs] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
promDatasourceApi.list().then(( r: any) => {
|
||||
const list = r?.data || r || [];
|
||||
setDs(list.find(( d: any) => d.is_default) || list[0] || null);
|
||||
}).catch(() => {}).finally(() => setLoading(false));
|
||||
}, []);
|
||||
const fetchSources = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = normalizeDatasourceList(await promDatasourceApi.list());
|
||||
setSources(list);
|
||||
setDs(current => list.find(d => String(d.id) === String(current?.id)) || list[0] || null);
|
||||
} catch { setSources([]); setDs(null); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
useEffect(() => { fetchSources(); }, []);
|
||||
if (loading) return <PageLoading label={t('monitor.dashboard.prometheus.loading')} />;
|
||||
if (!ds) return (
|
||||
<PageEmpty
|
||||
@@ -519,92 +578,10 @@ const PrometheusTab: React.FC = () => {
|
||||
action={<a href="/datasources" className="text-xs dg-text-blue hover:underline inline-flex items-center gap-1">{t('monitor.dashboard.zabbix.goConfig')} <ExternalLink className="w-3 h-3" /></a>}
|
||||
/>
|
||||
);
|
||||
return <PrometheusDashboard dsUrl={ds.url} />;
|
||||
};
|
||||
|
||||
// ═══════════ 自定义仪表盘 ═══════════
|
||||
interface Dashboard { id: string; name: string; description: string; datasource: string; datasourceUrl: string; panels: Panel[]; createdAt: string; updatedAt: string; createdBy: string; isDefault: boolean; }
|
||||
export interface Panel { id: string; title: string; type: string; query: string; datasource: string; width: number; height: number; x: number; y: number; options?: any; }
|
||||
|
||||
const DashboardTab: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||
const [active, setActive] = useState<Dashboard | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [nd, setNd] = useState({ name: '', description: '', datasource: 'prometheus' as const, datasourceUrl: '' });
|
||||
const [np, setNp] = useState({ title: '', type: 'graph' as const, query: '', width: 6, height: 4 });
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const fetch = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const res = await dashboardApi.getDashboards();
|
||||
const list = res.data || [];
|
||||
setDashboards(list);
|
||||
if (list.length > 0 && !active) setActive(list[0]);
|
||||
} catch (e: any) {
|
||||
toast.error(t('monitor.dashboard.custom.toastRefreshFailed', { msg: (e?.message || t('monitor.dashboard.agent.toast.unknownError')) }));
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => { fetch(); }, []);
|
||||
|
||||
const create = async () => {
|
||||
if (!nd.name) { toast.error(t('monitor.dashboard.custom.toastNameRequired')); return; }
|
||||
try { await dashboardApi.createDashboard({ ...nd, panels: [] }); toast.success(t('monitor.dashboard.custom.toastCreateSuccess')); setCreateOpen(false); setNd({ name: '', description: '', datasource: 'prometheus', datasourceUrl: '' }); fetch(); } catch { toast.error(t('monitor.dashboard.custom.toastCreateFailed')); }
|
||||
};
|
||||
const addPanel = async () => {
|
||||
if (!np.title || !np.query || !active) return;
|
||||
try {
|
||||
const p: Panel = { id: Date.now().toString(), ...np, datasource: active.datasource, x: 0, y: 0, options: { legend: true, tooltip: true, refreshInterval: 30 } };
|
||||
await dashboardApi.updateDashboard(active.id, { ...active, panels: [...active.panels, p] });
|
||||
toast.success(t('monitor.dashboard.custom.toastPanelAdded')); setAddOpen(false); setNp({ title: '', type: 'graph', query: '', width: 6, height: 4 }); fetch();
|
||||
} catch { toast.error(t('monitor.dashboard.custom.toastPanelFailed')); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={active?.id} onValueChange={id => { const d = dashboards.find(x => x.id === id); if (d) setActive(d); }}>
|
||||
<SelectTrigger className="w-[240px] h-8 text-xs"><SelectValue placeholder={t('monitor.dashboard.custom.selectDashboard')} /></SelectTrigger>
|
||||
<SelectContent>{dashboards.map(d => <SelectItem key={d.id} value={d.id}>{d.name}{d.isDefault ? ` ${t('monitor.dashboard.custom.defaultTag')}` : ''}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" className="h-8 text-[10px]" onClick={fetch} disabled={refreshing}>
|
||||
<RefreshCw className={cn('w-3 h-3 mr-1', refreshing && 'animate-spin')} />{t('monitor.dashboard.refresh')}
|
||||
</Button>
|
||||
{active && <Button variant="outline" size="sm" className="h-8 text-[10px]" onClick={() => setAddOpen(true)}><Plus className="w-3 h-3 mr-1" />{t('monitor.dashboard.custom.addPanel')}</Button>}
|
||||
<Button variant="outline" size="sm" className="h-8 text-[10px]" onClick={() => setCreateOpen(true)}><Plus className="w-3 h-3 mr-1" />{t('monitor.dashboard.custom.create')}</Button>
|
||||
</div>
|
||||
|
||||
{active ? (
|
||||
active.panels.length === 0 ? (
|
||||
<PageEmpty icon={<BarChart3 className="w-10 h-10" />} title={t('monitor.dashboard.custom.emptyNoPanels')} className="py-16" />
|
||||
) : (
|
||||
<div className="grid grid-cols-12 gap-3">
|
||||
{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} datasourceUrl={active.datasourceUrl} /></CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<PageEmpty icon={<Monitor className="w-10 h-10" />} title={t('monitor.dashboard.custom.emptyNoDashboards')} className="py-16" />
|
||||
)}
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}><DialogContent><DialogHeader><DialogTitle>{t('monitor.dashboard.custom.createTitle')}</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3"><div><Label>{t('monitor.dashboard.custom.name')}</Label><Input value={nd.name} onChange={e => setNd({ ...nd, name: e.target.value })} /></div>
|
||||
<div><Label>{t('monitor.dashboard.custom.datasource')}</Label><Select value={nd.datasource} onValueChange={v => setNd({ ...nd, datasource: v as any })}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="prometheus">Prometheus</SelectItem><SelectItem value="zabbix">Zabbix</SelectItem><SelectItem value="skywalking">SkyWalking</SelectItem><SelectItem value="custom">Custom</SelectItem></SelectContent></Select></div></div>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setCreateOpen(false)}>{t('monitor.dashboard.custom.cancel')}</Button><Button onClick={create}>{t('monitor.dashboard.custom.createBtn')}</Button></DialogFooter></DialogContent></Dialog>
|
||||
|
||||
<Dialog open={addOpen} onOpenChange={setAddOpen}><DialogContent><DialogHeader><DialogTitle>{t('monitor.dashboard.custom.addPanelTitle')}</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3"><div><Label>{t('monitor.dashboard.custom.panelTitle')}</Label><Input value={np.title} onChange={e => setNp({ ...np, title: e.target.value })} /></div>
|
||||
<div><Label>{t('monitor.dashboard.custom.panelType')}</Label><Select value={np.type} onValueChange={v => setNp({ ...np, type: v as any })}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="graph">{t('monitor.dashboard.custom.typeGraph')}</SelectItem><SelectItem value="stat">{t('monitor.dashboard.custom.typeStat')}</SelectItem><SelectItem value="gauge">{t('monitor.dashboard.custom.typeGauge')}</SelectItem><SelectItem value="table">{t('monitor.dashboard.custom.typeTable')}</SelectItem></SelectContent></Select></div>
|
||||
<div><Label>{t('monitor.dashboard.custom.panelQuery')}</Label><Input value={np.query} onChange={e => setNp({ ...np, query: e.target.value })} /></div></div>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setAddOpen(false)}>{t('monitor.dashboard.custom.cancel')}</Button><Button onClick={addPanel}>{t('monitor.dashboard.custom.addBtn')}</Button></DialogFooter></DialogContent></Dialog>
|
||||
<div>
|
||||
<DatasourceToolbar sources={sources} activeId={String(ds.id)} onChange={(id) => setDs(sources.find(item => String(item.id) === id) || ds)} onRefresh={fetchSources} refreshing={loading} />
|
||||
<PrometheusDashboard key={String(ds.id)} dsUrl={ds.url} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -638,7 +615,6 @@ const GrafanaDashboard: React.FC = () => {
|
||||
{tab === 'agent' && <AgentTab />}
|
||||
{tab === 'zabbix' && <ZabbixTab />}
|
||||
{tab === 'prometheus' && <PrometheusTab />}
|
||||
{tab === 'dashboard' && <DashboardTab />}
|
||||
</PageShell>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
/* 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';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
// 默认 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(i18n.t('monitor.dashboard.prometheus.emptyTitle'));
|
||||
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(i18n.t('kubernetes.configmap.noData'));
|
||||
} else {
|
||||
// stat / gauge / table 用即时查询
|
||||
const res = await prometheusProxyApi.query(url, panel.query);
|
||||
const parsed = parseInstantResult(res);
|
||||
setInstantData(parsed);
|
||||
if (parsed.length === 0) setError(i18n.t('kubernetes.configmap.noData'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message ? String(e.message).slice(0, 80) : i18n.t('monitor.logPanel.queryFailed'));
|
||||
} 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> {i18n.t('monitor.hostDashboard.realtime')} </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)]"> {i18n.t('kubernetes.configmap.noData')} </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)]"> {i18n.t('kubernetes.configmap.noData')} </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>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
// Prometheus 数据源面板 — 接受外部配置 // Logan.Li
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Server, Loader2, Activity, Cpu, RefreshCw, AlertTriangle, Container, Zap} from 'lucide-react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Server, Loader2, Activity, Cpu, RefreshCw, AlertTriangle, Container, Zap, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { StatStrip } from '@/components/ui/stat-strip';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { prometheusProxyApi } from '@/api';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
interface Props { dsUrl?: string; }
|
||||
|
||||
@@ -37,6 +37,23 @@ const fmtBps = (b: number) => {
|
||||
if (b > 1e3) return `${(b / 1e3).toFixed(1)} KB/s`;
|
||||
return `${b.toFixed(1)} B/s`;
|
||||
};
|
||||
const fmtCores = (v: number) => v >= 1 ? `${v.toFixed(2)} 核` : `${(v * 1000).toFixed(0)} m`;
|
||||
const podName = (p: JsonData) => `${p.namespace ? `${p.namespace}/` : ''}${p.pod || 'unknown'}`;
|
||||
|
||||
const metricValue = (item?: JsonData): number => Number(item?.value ?? Number.NaN);
|
||||
const metricKey = (item?: JsonData): string => String(item?.instance || item?.exported_instance || item?.node || item?.nodename || item?.kubernetes_node || '');
|
||||
const sameMetricTarget = (item: JsonData, keys: Set<string>) => keys.has(metricKey(item));
|
||||
const promVector = (result: JsonData): JsonData[] => ((result?.data as JsonData)?.result as JsonData[]) || [];
|
||||
const promFirstValue = (result: JsonData): number => Number(promVector(result)[0]?.value?.[1] || 0);
|
||||
const promLabelValues = (result: JsonData, label: string): string[] => promVector(result).map((r: JsonData) => String((r.metric as JsonData)?.[label] || '')).filter(Boolean).sort();
|
||||
const labelMatcher = (application: string, instance: string) => `application="${application.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}", instance="${instance.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
||||
const nodeKeys = (node: JsonData): Set<string> => new Set([
|
||||
node.instance,
|
||||
node.exported_instance,
|
||||
node.node,
|
||||
node.nodename,
|
||||
node.kubernetes_node,
|
||||
].filter(Boolean).map(String));
|
||||
|
||||
function parsePrune( result: JsonData[]): JsonData[] {
|
||||
return (result || []).map((r: JsonData) => {
|
||||
@@ -59,7 +76,7 @@ const K8sPanel: React.FC<{ d: JsonData}> = ({ d }) => (
|
||||
]}
|
||||
/>
|
||||
<div className="rounded-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] p-3 max-h-60 overflow-y-auto">
|
||||
<div className="text-xs font-medium mb-2 flex items-center gap-1"><Cpu className="w-3 h-3 dg-text-purple" /> {i18n.t('monitor.prometheusdashb.se7fb2b41')} </div>
|
||||
<div className="text-xs font-medium mb-2 flex items-center gap-1"><Cpu className="w-3 h-3 dg-text-purple" />节点 CPU / 内存利用率</div>
|
||||
{(d.nodeCPU || []).map((n: JsonData, i: number) => {
|
||||
const mem = (d.nodeMem || []).find((m: JsonData) => m.node === n.node);
|
||||
return <div key={i} className="flex items-center gap-3 text-[11px] mb-1.5"><span className="w-28 truncate" title={n.node}>{n.node}</span><MiniBar val={Number(n.value||0)} label={`${Number(n.value||0).toFixed(1)}%`} /><MiniBar val={Number(mem?.value||0)} label={`${Number(mem?.value||0).toFixed(1)}%`} color="bg-blue-500" /></div>;
|
||||
@@ -68,33 +85,181 @@ const K8sPanel: React.FC<{ d: JsonData}> = ({ d }) => (
|
||||
</div>
|
||||
);
|
||||
|
||||
const NodePanel: React.FC<{ d: JsonData}> = ({ d }) => (
|
||||
<div className="overflow-x-auto rounded-lg border border-[var(--dg-border)]">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)]"><tr><th className="px-3 py-2 text-left"> {i18n.t('kubernetes.node.title')} </th><th className="px-3 py-2 text-left"> {i18n.t('system.terminalStatus')} </th><th className="px-3 py-2 text-left">CPU</th><th className="px-3 py-2 text-left"> {i18n.t('kubernetes.pod.memory')} </th><th className="px-3 py-2 text-left"> {i18n.t('monitor.dashboard.col.disk')} </th><th className="px-3 py-2 text-left"> {i18n.t('monitor.prometheusdashb.s361ffa7f')} </th><th className="px-3 py-2 text-left"> {i18n.t('monitor.prometheusdashb.s37cc0a9a')} </th></tr></thead>
|
||||
<tbody>
|
||||
{(d.nodes || []).map((n: JsonData, i: number) => {
|
||||
const inst = n.instance || n.exported_instance || '';
|
||||
const cpu = (d.cpuUsage||[]).find((x: JsonData)=>x.instance===inst);
|
||||
const mem = (d.memUsage||[]).find((x: JsonData)=>x.instance===inst);
|
||||
const disk = (d.diskUsage||[]).find((x: JsonData)=>x.instance===inst);
|
||||
const up = (d.up||[]).find((x: JsonData)=>x.instance===inst);
|
||||
const rx = (d.netRx||[]).find((x: JsonData)=>x.instance===inst);
|
||||
const tx = (d.netTx||[]).find((x: JsonData)=>x.instance===inst);
|
||||
return <tr key={i} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<td className="px-3 py-2 font-medium">{n.nodename||inst}</td>
|
||||
<td className="px-3 py-2"><Badge className={`text-[10px] ${up?.value===1?'bg-emerald-500/20 dg-text-green':'bg-red-500/20 dg-text-red'}`}>{up?.value===1?'UP':'DOWN'}</Badge></td>
|
||||
<td className="px-3 py-2"><MiniBar val={Number(cpu?.value||0)} label={`${Number(cpu?.value||0).toFixed(1)}%`} /></td>
|
||||
<td className="px-3 py-2"><MiniBar val={Number(mem?.value||0)} label={`${Number(mem?.value||0).toFixed(1)}%`} color="bg-blue-500" /></td>
|
||||
<td className="px-3 py-2"><MiniBar val={Number(disk?.value||0)} label={`${Number(disk?.value||0).toFixed(1)}%`} color="bg-amber-500" /></td>
|
||||
<td className="px-3 py-2 font-mono text-[var(--dg-text-secondary)]">{fmtBps(Number(rx?.value||0))}</td>
|
||||
<td className="px-3 py-2 font-mono text-[var(--dg-text-secondary)]">{fmtBps(Number(tx?.value||0))}</td>
|
||||
</tr>;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
type NodeSortField = 'name' | 'cpu' | 'mem' | 'disk' | 'rx' | 'tx';
|
||||
|
||||
const NodePanel: React.FC<{ d: JsonData}> = ({ d }) => {
|
||||
const [sortField, setSortField] = useState<NodeSortField>('cpu');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
const rows = useMemo(() => (d.nodes || []).map((n: JsonData) => {
|
||||
const keys = nodeKeys(n);
|
||||
const cpu = (d.cpuUsage || []).find((x: JsonData) => sameMetricTarget(x, keys));
|
||||
const mem = (d.memUsage || []).find((x: JsonData) => sameMetricTarget(x, keys));
|
||||
const disk = (d.diskUsage || []).find((x: JsonData) => sameMetricTarget(x, keys));
|
||||
const rx = (d.netRx || []).find((x: JsonData) => sameMetricTarget(x, keys));
|
||||
const tx = (d.netTx || []).find((x: JsonData) => sameMetricTarget(x, keys));
|
||||
return {
|
||||
name: String(n.nodename || n.node || n.instance || n.exported_instance || 'unknown'),
|
||||
cpu: metricValue(cpu) || 0,
|
||||
mem: metricValue(mem) || 0,
|
||||
disk: metricValue(disk) || 0,
|
||||
rx: metricValue(rx) || 0,
|
||||
tx: metricValue(tx) || 0,
|
||||
};
|
||||
}), [d]);
|
||||
|
||||
const sorted = useMemo(() => [...rows].sort((a, b) => {
|
||||
const va = a[sortField];
|
||||
const vb = b[sortField];
|
||||
if (typeof va === 'number' && typeof vb === 'number') return sortDir === 'asc' ? va - vb : vb - va;
|
||||
return sortDir === 'asc' ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
|
||||
}), [rows, sortField, sortDir]);
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
|
||||
const safePage = Math.min(page, totalPages);
|
||||
const paged = sorted.slice((safePage - 1) * pageSize, safePage * pageSize);
|
||||
const toggleSort = (f: NodeSortField) => {
|
||||
if (sortField === f) setSortDir(dv => dv === 'asc' ? 'desc' : 'asc');
|
||||
else { setSortField(f); setSortDir('desc'); }
|
||||
};
|
||||
const SI = ({ f }: { f: NodeSortField }) => sortField !== f ? <ArrowUpDown className="w-3 h-3 dg-text-muted" /> : sortDir === 'asc' ? <ArrowUp className="w-3 h-3 dg-text-purple" /> : <ArrowDown className="w-3 h-3 dg-text-purple" />;
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border border-[var(--dg-border)]">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)]"><tr>
|
||||
{[
|
||||
{ f: 'name' as NodeSortField, l: '节点' }, { f: 'cpu' as NodeSortField, l: 'CPU' },
|
||||
{ f: 'mem' as NodeSortField, l: '内存' }, { f: 'disk' as NodeSortField, l: '磁盘' }, { f: 'rx' as NodeSortField, l: '流入' }, { f: 'tx' as NodeSortField, l: '流出' },
|
||||
].map(({ f, l }) => (
|
||||
<th key={f} className="px-3 py-2 text-left cursor-pointer select-none" onClick={() => toggleSort(f)}><div className="flex items-center gap-1">{l}<SI f={f} /></div></th>
|
||||
))}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{paged.map((n, i) => (
|
||||
<tr key={n.name + i} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<td className="px-3 py-2 font-medium">{n.name}</td>
|
||||
<td className="px-3 py-2"><MiniBar val={n.cpu} label={`${n.cpu.toFixed(1)}%`} /></td>
|
||||
<td className="px-3 py-2"><MiniBar val={n.mem} label={`${n.mem.toFixed(1)}%`} color="bg-blue-500" /></td>
|
||||
<td className="px-3 py-2"><MiniBar val={n.disk} label={`${n.disk.toFixed(1)}%`} color="bg-amber-500" /></td>
|
||||
<td className="px-3 py-2 font-mono text-[var(--dg-text-secondary)]">{fmtBps(n.rx)}</td>
|
||||
<td className="px-3 py-2 font-mono text-[var(--dg-text-secondary)]">{fmtBps(n.tx)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{sorted.length > pageSize && (
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs">
|
||||
<div className="flex items-center gap-2 text-[var(--dg-text-muted)]">
|
||||
<span>共 {sorted.length} 个节点</span>
|
||||
<select className="bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded px-2 py-0.5 text-xs" value={pageSize} onChange={e => { setPageSize(Number(e.target.value)); setPage(1); }}>
|
||||
<option value={10}>10/页</option><option value={20}>20/页</option><option value={50}>50/页</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button className="px-2 py-0.5 rounded hover:bg-[var(--dg-bg-primary)] disabled:opacity-30" disabled={safePage <= 1} onClick={() => setPage(Math.max(1, safePage - 1))}>上一页</button>
|
||||
<span className="px-2 text-[var(--dg-text-primary)]">{safePage} / {totalPages}</span>
|
||||
<button className="px-2 py-0.5 rounded hover:bg-[var(--dg-bg-primary)] disabled:opacity-30" disabled={safePage >= totalPages} onClick={() => setPage(Math.min(totalPages, safePage + 1))}>下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SimpleRank: React.FC<{
|
||||
title: string;
|
||||
rows: JsonData[];
|
||||
valueText: (v: number) => string;
|
||||
color?: string;
|
||||
}> = ({ title, rows, valueText, color = 'bg-purple-500' }) => {
|
||||
const max = Math.max(...rows.map(r => Number(r.value || 0)), 1);
|
||||
return (
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]">
|
||||
<CardHeader className="py-2 px-3">
|
||||
<CardTitle className="text-xs font-medium">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3 pt-0">
|
||||
<div className="space-y-2 max-h-[320px] overflow-y-auto pr-1">
|
||||
{rows.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-[var(--dg-text-muted)]">暂无数据</div>
|
||||
) : rows.slice(0, 10).map((p: JsonData, i: number) => {
|
||||
const value = Number(p.value || 0);
|
||||
return (
|
||||
<div key={`${podName(p)}-${i}`} className="flex items-center gap-2 text-[11px]">
|
||||
<span className="w-6 text-right text-[var(--dg-text-muted)]">{i + 1}</span>
|
||||
<span className="w-40 truncate" title={podName(p)}>{podName(p)}</span>
|
||||
<div className="flex-1 h-2 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
|
||||
<div className={`h-full rounded-full ${color}`} style={{ width: `${Math.max(4, Math.min(100, (value / max) * 100))}%` }} />
|
||||
</div>
|
||||
<span className="w-20 text-right font-mono text-[var(--dg-text-secondary)]">{valueText(value)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const PodNetworkRank: React.FC<{ rx: JsonData[]; tx: JsonData[] }> = ({ rx, tx }) => {
|
||||
const rows = useMemo(() => {
|
||||
const merged = new Map<string, { name: string; rx: number; tx: number; total: number }>();
|
||||
for (const item of rx || []) {
|
||||
const name = podName(item);
|
||||
const current = merged.get(name) || { name, rx: 0, tx: 0, total: 0 };
|
||||
current.rx = Number(item.value || 0);
|
||||
current.total = current.rx + current.tx;
|
||||
merged.set(name, current);
|
||||
}
|
||||
for (const item of tx || []) {
|
||||
const name = podName(item);
|
||||
const current = merged.get(name) || { name, rx: 0, tx: 0, total: 0 };
|
||||
current.tx = Number(item.value || 0);
|
||||
current.total = current.rx + current.tx;
|
||||
merged.set(name, current);
|
||||
}
|
||||
return [...merged.values()].sort((a, b) => b.total - a.total).slice(0, 10);
|
||||
}, [rx, tx]);
|
||||
|
||||
return (
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]">
|
||||
<CardHeader className="py-2 px-3">
|
||||
<CardTitle className="text-xs font-medium">Pod 流量网卡 Top 10</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-y border-[var(--dg-border)]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">Pod</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">流入</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">流出</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">总流量</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr><td colSpan={4} className="px-3 py-8 text-center text-[var(--dg-text-muted)]">暂无数据</td></tr>
|
||||
) : rows.map((p, i) => (
|
||||
<tr key={p.name} className="border-b border-[var(--dg-border)] last:border-0 hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<td className="px-3 py-2">
|
||||
<span className="mr-2 text-[var(--dg-text-muted)]">{i + 1}</span>
|
||||
<span className="font-medium text-[var(--dg-text-primary)]">{p.name}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-mono text-[var(--dg-text-secondary)]">{fmtBps(p.rx)}</td>
|
||||
<td className="px-3 py-2 text-right font-mono text-[var(--dg-text-secondary)]">{fmtBps(p.tx)}</td>
|
||||
<td className="px-3 py-2 text-right font-mono text-[var(--dg-text-primary)]">{fmtBps(p.total)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const PodPanel: React.FC<{ d: JsonData}> = ({ d }) => (
|
||||
<div className="space-y-4">
|
||||
@@ -104,21 +269,126 @@ const PodPanel: React.FC<{ d: JsonData}> = ({ d }) => (
|
||||
{ label: 'Pending', value: d.pending?.[0]?.value || 0, color: 'amber' },
|
||||
]}
|
||||
/>
|
||||
<div className="rounded-lg bg-[var(--dg-bg-primary)] p-3 max-h-40 overflow-y-auto"><div className="text-xs font-medium mb-2">CPU Top 10</div>{(d.cpuTop||[]).map((p: JsonData, i:number)=><div key={i} className="flex items-center gap-2 text-[11px] mb-1"><span className="w-10 text-right text-[var(--dg-text-muted)]">{i+1}</span><span className="w-32 truncate" title={p.pod}>{p.pod}</span><MiniBar val={Number(p.value||0)*100} label={`${Number(p.value||0).toFixed(2)}c`} color="bg-purple-500" /></div>)}</div>
|
||||
<div className="rounded-lg bg-[var(--dg-bg-primary)] p-3 max-h-40 overflow-y-auto"><div className="text-xs font-medium mb-2"> {i18n.t('monitor.prometheusdashb.sb0f653eb')} </div>{(d.memTop||[]).map((p: JsonData, i:number)=><div key={i} className="flex items-center gap-2 text-[11px] mb-1"><span className="w-10 text-right text-[var(--dg-text-muted)]">{i+1}</span><span className="w-32 truncate" title={p.pod}>{p.pod}</span><span className="ml-auto">{fmtBytes(Number(p.value||0))}</span></div>)}</div>
|
||||
<PodNetworkRank rx={d.netRxTop || []} tx={d.netTxTop || []} />
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<SimpleRank title="Pod CPU Top 10" rows={d.cpuTop || []} valueText={fmtCores} color="bg-purple-500" />
|
||||
<SimpleRank title="Pod 内存 Top 10" rows={d.memTop || []} valueText={fmtBytes} color="bg-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const JvmPanel: React.FC<{ d: JsonData}> = ({ d }) => {
|
||||
const h = Number(d.heapUsed?.[0]?.value||0);
|
||||
const uptime = Number(d.uptime?.[0]?.value||0);
|
||||
const JvmPanel: React.FC<{ dsUrl?: string; fallback: JsonData }> = ({ dsUrl, fallback }) => {
|
||||
const [applications, setApplications] = useState<string[]>([]);
|
||||
const [instances, setInstances] = useState<string[]>([]);
|
||||
const [application, setApplication] = useState('');
|
||||
const [instance, setInstance] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [metrics, setMetrics] = useState<JsonData>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!dsUrl) return;
|
||||
let cancelled = false;
|
||||
prometheusProxyApi.query(dsUrl, 'count by(application) (jvm_memory_used_bytes)')
|
||||
.then((res: JsonData) => {
|
||||
if (cancelled) return;
|
||||
const values = promLabelValues(res, 'application');
|
||||
setApplications(values);
|
||||
setApplication(current => current && values.includes(current) ? current : values[0] || '');
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setApplications([]);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [dsUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dsUrl || !application) { setInstances([]); setInstance(''); return; }
|
||||
let cancelled = false;
|
||||
const app = application.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
|
||||
prometheusProxyApi.query(dsUrl, `count by(instance) (jvm_memory_used_bytes{application="${app}"})`)
|
||||
.then((res: JsonData) => {
|
||||
if (cancelled) return;
|
||||
const values = promLabelValues(res, 'instance');
|
||||
setInstances(values);
|
||||
setInstance(current => current && values.includes(current) ? current : values[0] || '');
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setInstances([]);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [dsUrl, application]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dsUrl || !application || !instance) return;
|
||||
let cancelled = false;
|
||||
const loadMetrics = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const m = labelMatcher(application, instance);
|
||||
const [uptime, heapUsed, nonHeapUsed, threadCount, gcPause, cpuLoad] = await Promise.all([
|
||||
prometheusProxyApi.query(dsUrl, `process_uptime_seconds{${m}}`),
|
||||
prometheusProxyApi.query(dsUrl, `sum(jvm_memory_used_bytes{${m},area="heap"}) / sum(jvm_memory_max_bytes{${m},area="heap"}) * 100`),
|
||||
prometheusProxyApi.query(dsUrl, `sum(jvm_memory_used_bytes{${m},area="nonheap"}) / sum(jvm_memory_max_bytes{${m},area="nonheap"}) * 100`),
|
||||
prometheusProxyApi.query(dsUrl, `jvm_threads_live_threads{${m}}`),
|
||||
prometheusProxyApi.query(dsUrl, `sum(rate(jvm_gc_pause_seconds_sum{${m}}[5m]))`),
|
||||
prometheusProxyApi.query(dsUrl, `process_cpu_usage{${m}} * 100`),
|
||||
]);
|
||||
if (!cancelled) {
|
||||
setMetrics({
|
||||
uptime: promFirstValue(uptime),
|
||||
heapUsed: promFirstValue(heapUsed),
|
||||
nonHeapUsed: promFirstValue(nonHeapUsed),
|
||||
threadCount: promFirstValue(threadCount),
|
||||
gcPause: promFirstValue(gcPause),
|
||||
cpuLoad: promFirstValue(cpuLoad),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setMetrics({});
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
loadMetrics();
|
||||
return () => { cancelled = true; };
|
||||
}, [dsUrl, application, instance]);
|
||||
|
||||
const h = Number(metrics.heapUsed ?? fallback.heapUsed?.[0]?.value ?? 0);
|
||||
const uptime = Number(metrics.uptime ?? fallback.uptime?.[0]?.value ?? 0);
|
||||
const nonHeap = Number(metrics.nonHeapUsed ?? fallback.nonHeapUsed?.[0]?.value ?? 0);
|
||||
const threads = Number(metrics.threadCount ?? fallback.threadCount?.[0]?.value ?? 0);
|
||||
const gcPause = Number(metrics.gcPause ?? fallback.gcPause?.[0]?.value ?? 0);
|
||||
const cpuLoad = Number(metrics.cpuLoad ?? fallback.cpuLoad?.[0]?.value ?? 0);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Select value={application} onValueChange={setApplication}>
|
||||
<SelectTrigger className="w-[260px] h-8 text-xs dg-input">
|
||||
<SelectValue placeholder="选择 Application" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="dg-input">
|
||||
{applications.map(app => <SelectItem key={app} value={app}>{app}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={instance} onValueChange={setInstance}>
|
||||
<SelectTrigger className="w-[280px] h-8 text-xs dg-input">
|
||||
<SelectValue placeholder="选择 Instance" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="dg-input">
|
||||
{instances.map(ins => <SelectItem key={ins} value={ins}>{ins}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{loading && <span className="text-[11px] text-[var(--dg-text-muted)] inline-flex items-center gap-1"><Loader2 className="w-3 h-3 animate-spin" />加载 JVM 指标</span>}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
|
||||
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><div className="text-lg font-bold">{uptime>86400?`${(uptime/86400).toFixed(1)}d`:`${(uptime/3600).toFixed(1)}h`}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Uptime</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><MiniBar val={h} label={`${h.toFixed(1)}%`} color={h>80?'bg-orange-500':'bg-purple-500'} /><div className="text-[10px] text-[var(--dg-text-muted)] mt-1">Heap</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><MiniBar val={Number(d.nonHeapUsed?.[0]?.value||0)} label={`${Number(d.nonHeapUsed?.[0]?.value||0).toFixed(1)}%`} color="bg-blue-500" /><div className="text-[10px] text-[var(--dg-text-muted)] mt-1">Non-Heap</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><div className="text-lg font-bold dg-text-green">{d.threadCount?.[0]?.value||0}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Threads</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><div className="text-lg font-bold dg-text-yellow">{Number(d.gcPause?.[0]?.value||0).toFixed(3)}</div><div className="text-[10px] text-[var(--dg-text-muted)]">GC Pause (s)</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><MiniBar val={nonHeap} label={`${nonHeap.toFixed(1)}%`} color="bg-blue-500" /><div className="text-[10px] text-[var(--dg-text-muted)] mt-1">Non-Heap</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><div className="text-lg font-bold dg-text-green">{threads || 0}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Threads</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><div className="text-lg font-bold dg-text-yellow">{gcPause.toFixed(3)}</div><div className="text-[10px] text-[var(--dg-text-muted)]">GC Pause (s)</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)]"><CardContent className="p-3 text-center"><MiniBar val={cpuLoad} label={`${cpuLoad.toFixed(1)}%`} color="bg-emerald-500" /><div className="text-[10px] text-[var(--dg-text-muted)] mt-1">Process CPU</div></CardContent></Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -144,7 +414,7 @@ export const PrometheusDashboard: React.FC<Props> = ({ dsUrl }) => {
|
||||
const data: JsonData = await prometheusProxyApi.connect(u);
|
||||
setConnected(true); setTargets(data.targets||0);
|
||||
loadAll(u);
|
||||
} catch (e) { setConnected(false); setError((e as JsonData).message||i18n.t('aiops.llmTestFailed')); }
|
||||
} catch (e) { setConnected(false); setError((e as JsonData).message||'连接失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
@@ -157,7 +427,9 @@ export const PrometheusDashboard: React.FC<Props> = ({ dsUrl }) => {
|
||||
items[k] = Array.isArray(v) ? v : ((v as JsonData)?.data?.result ? parsePrune((v as JsonData).data.result) : v);
|
||||
if (t==='kubernetes') setK8sD(items); if (t==='node') setNodeD(items);
|
||||
if (t==='pod') setPodD(items); if (t==='jvm') setJvmD(items);
|
||||
} catch {}
|
||||
} catch {
|
||||
// 单类指标失败不阻断其他指标加载。// Logan.Li
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -172,11 +444,11 @@ export const PrometheusDashboard: React.FC<Props> = ({ dsUrl }) => {
|
||||
<Activity className="h-4 w-4 dg-text-purple" />
|
||||
<CardTitle className="text-sm font-medium">Prometheus 监控</CardTitle>
|
||||
{connected && <Badge className="text-[10px] bg-purple-500/10 dg-text-purple">{targets} targets</Badge>}
|
||||
{connected && <div className="ml-auto"><Button variant="ghost" size="sm" className="h-6 px-2 text-[10px]" onClick={refresh}><RefreshCw className="w-3 h-3 mr-1" /> {i18n.t('monitor.dashboard.refresh')} </Button></div>}
|
||||
{connected && <div className="ml-auto"><Button variant="ghost" size="sm" className="h-6 px-2 text-[10px]" onClick={refresh}><RefreshCw className="w-3 h-3 mr-1" />刷新</Button></div>}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-3 pt-0">
|
||||
{loading ? (<div className="text-center py-12"><Loader2 className="h-6 w-6 mx-auto mb-2 animate-spin dg-text-purple" /><p className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('monitor.prometheusdashb.s5e8eb212')} </p></div>)
|
||||
{loading ? (<div className="text-center py-12"><Loader2 className="h-6 w-6 mx-auto mb-2 animate-spin dg-text-purple" /><p className="text-xs text-[var(--dg-text-muted)]">连接中…</p></div>)
|
||||
: error ? (<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-xs dg-text-red"><AlertTriangle className="h-4 w-4 inline mr-1" />{error}</div>)
|
||||
: (
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
@@ -189,7 +461,7 @@ export const PrometheusDashboard: React.FC<Props> = ({ dsUrl }) => {
|
||||
<TabsContent value="kubernetes"><K8sPanel d={k8sD} /></TabsContent>
|
||||
<TabsContent value="node"><NodePanel d={nodeD} /></TabsContent>
|
||||
<TabsContent value="pod"><PodPanel d={podD} /></TabsContent>
|
||||
<TabsContent value="jvm"><JvmPanel d={jvmD} /></TabsContent>
|
||||
<TabsContent value="jvm"><JvmPanel dsUrl={dsUrl} fallback={jvmD} /></TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -3,7 +3,6 @@ import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
|
||||
Server,
|
||||
Wifi,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
Activity,
|
||||
@@ -39,6 +38,8 @@ export const ZabbixDashboard: React.FC<Props> = ({ dsUrl, dsUsername, dsPassword
|
||||
const [error, setError] = useState('');
|
||||
const [sortField, setSortField] = useState<SortField>('cpu_usage');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
if (dsUrl && dsUsername && dsPassword) doConnect(dsUrl, dsUsername, dsPassword);
|
||||
@@ -65,6 +66,9 @@ export const ZabbixDashboard: React.FC<Props> = ({ dsUrl, dsUsername, dsPassword
|
||||
if (typeof va === 'number') return sortDir === 'asc' ? va - vb : vb - va;
|
||||
return sortDir === 'asc' ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
|
||||
});
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
|
||||
const safePage = Math.min(page, totalPages);
|
||||
const paged = sorted.slice((safePage - 1) * pageSize, safePage * pageSize);
|
||||
|
||||
const SI = ({ f }: { f: SortField }) => {
|
||||
if (sortField !== f) return <ArrowUpDown className="w-3 h-3 dg-text-muted" />;
|
||||
@@ -118,9 +122,9 @@ export const ZabbixDashboard: React.FC<Props> = ({ dsUrl, dsUsername, dsPassword
|
||||
<div className="flex items-center gap-1">{l}<SI f={f} /></div></th>
|
||||
))}</tr></thead>
|
||||
<tbody>
|
||||
{sorted.map((h, i) => (
|
||||
{paged.map((h, i) => (
|
||||
<tr key={h.hostid || i} className="border-b border-[var(--dg-border)] last:border-0 hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<td className="px-3 py-2.5 whitespace-nowrap"><Badge className={`text-[10px] ${h.available === '1' ? 'bg-emerald-500/20 dg-text-green' : 'bg-red-500/20 dg-text-red'}`}><Wifi className="w-3 h-3 mr-1" />{h.available === '1' ? 'UP' : 'DOWN'}</Badge> <span className="font-medium text-[var(--dg-text-primary)] ml-1">{h.name || h.hostname}</span></td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap font-medium text-[var(--dg-text-primary)]">{h.name || h.hostname}</td>
|
||||
<td className="px-3 py-2.5">{bar(Number(h.cpu_usage || 0), `${Number(h.cpu_usage || 0).toFixed(1)}%`)}</td>
|
||||
<td className="px-3 py-2.5">{bar(Number(h.memory_usage || 0), `${Number(h.memory_usage || 0).toFixed(1)}%`)}</td>
|
||||
<td className="px-3 py-2.5">{bar(Number(h.disk_usage || 0), `${Number(h.disk_usage || 0).toFixed(1)}%`)}</td>
|
||||
@@ -133,6 +137,21 @@ export const ZabbixDashboard: React.FC<Props> = ({ dsUrl, dsUsername, dsPassword
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{sorted.length > pageSize && (
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs">
|
||||
<div className="flex items-center gap-2 text-[var(--dg-text-muted)]">
|
||||
<span>共 {sorted.length} 台主机</span>
|
||||
<select className="bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded px-2 py-0.5 text-xs" value={pageSize} onChange={e => { setPageSize(Number(e.target.value)); setPage(1); }}>
|
||||
<option value={10}>10/页</option><option value={20}>20/页</option><option value={50}>50/页</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button className="px-2 py-0.5 rounded hover:bg-[var(--dg-bg-primary)] disabled:opacity-30" disabled={safePage <= 1} onClick={() => setPage(Math.max(1, safePage - 1))}>上一页</button>
|
||||
<span className="px-2 text-[var(--dg-text-primary)]">{safePage} / {totalPages}</span>
|
||||
<button className="px-2 py-0.5 rounded hover:bg-[var(--dg-bg-primary)] disabled:opacity-30" disabled={safePage >= totalPages} onClick={() => setPage(Math.min(totalPages, safePage + 1))}>下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -3,8 +3,8 @@ import { useState, useEffect } from 'react';
|
||||
import { X, BellRing, Loader2, Flame, Database, Cloud } from 'lucide-react';
|
||||
import { alertApi } from '@/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { getErrorMessage } from '@/lib/utils';
|
||||
import type { LogEngine } from './types-v3';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
interface LogQueryContext {
|
||||
engine: LogEngine;
|
||||
@@ -74,11 +74,11 @@ export function LogAlertFromQueryDialog({ open, onOpenChange, ctx, onSaved }: Pr
|
||||
notify_channels: [],
|
||||
});
|
||||
setSaved(true);
|
||||
toast({ title: i18n.t('monitor.savequerydialog.sd3dbea92') });
|
||||
toast({ title: '告警规则已创建' });
|
||||
onSaved?.();
|
||||
setTimeout(() => onOpenChange(false), 800);
|
||||
} catch (e: any) {
|
||||
toast({ title: i18n.t('monitor.dashboard.custom.toastCreateFailed'), description: e?.message || String(e), variant: 'destructive' });
|
||||
} catch (e: unknown) {
|
||||
toast({ title: '创建失败', description: getErrorMessage(e), variant: 'destructive' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -95,7 +95,7 @@ export function LogAlertFromQueryDialog({ open, onOpenChange, ctx, onSaved }: Pr
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: 'var(--dg-border)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<BellRing className="h-5 w-5" style={{ color: '#f59e0b' }} />
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}> {i18n.t('monitor.logalertfromque.s5f470749')} </span>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>创建告警规则</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
@@ -113,12 +113,12 @@ export function LogAlertFromQueryDialog({ open, onOpenChange, ctx, onSaved }: Pr
|
||||
<span className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded font-medium" style={{ background: `${color}15`, color }}>
|
||||
<Icon className="h-3 w-3" />{meta.name}
|
||||
</span>
|
||||
<span className="text-[11px] truncate" style={{ color: 'var(--dg-text-secondary)' }}>{ctx.datasourceName || i18n.t('monitor.savequerydialog.sddb4ca92')}</span>
|
||||
<span className="text-[11px] truncate" style={{ color: 'var(--dg-text-secondary)' }}>{ctx.datasourceName || '默认数据源'}</span>
|
||||
<span className="text-[10px] ml-auto" style={{ color: 'var(--dg-text-muted)' }}>{ctx.timeRange || '1h'}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}> {i18n.t('monitor.silenceDialog.ruleNameRequired')} </label>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>规则名称 *</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
@@ -128,7 +128,7 @@ export function LogAlertFromQueryDialog({ open, onOpenChange, ctx, onSaved }: Pr
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}> {i18n.t('aiops.llmModelDesc')} </label>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>描述</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
@@ -139,7 +139,7 @@ export function LogAlertFromQueryDialog({ open, onOpenChange, ctx, onSaved }: Pr
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}> {i18n.t('monitor.logalertfromque.sa6c48ab1')} </label>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>触发条件 *</label>
|
||||
<textarea
|
||||
value={expression}
|
||||
onChange={e => setExpression(e.target.value)}
|
||||
@@ -148,26 +148,26 @@ export function LogAlertFromQueryDialog({ open, onOpenChange, ctx, onSaved }: Pr
|
||||
style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-primary)' }}
|
||||
/>
|
||||
<p className="text-[10px] mt-1" style={{ color: 'var(--dg-text-muted)' }}>
|
||||
{ctx.engine === 'loki' ? i18n.t('monitor.logalertfromque.s0d669ee6') : ctx.engine === 'sls' ? i18n.t('monitor.logalertfromque.sf769e374') : i18n.t('monitor.logalertfromque.sa079c76e')}
|
||||
{ctx.engine === 'loki' ? 'LogQL 告警表达式' : ctx.engine === 'sls' ? 'SLS SQL/SPL 查询' : 'ES Lucene 查询'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}> {i18n.t('kubernetes.event.eventLevel')} </label>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>级别</label>
|
||||
<select
|
||||
value={severity}
|
||||
onChange={e => setSeverity(e.target.value)}
|
||||
className="w-full h-9 px-2 text-xs rounded dg-input"
|
||||
style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-primary)' }}
|
||||
>
|
||||
<option value="info"> {i18n.t('monitor.alertEvents.severity.info')} </option>
|
||||
<option value="warning"> {i18n.t('kubernetes.event.warning')} </option>
|
||||
<option value="critical"> {i18n.t('monitor.alertHelper.sevCritical')} </option>
|
||||
<option value="info">提示</option>
|
||||
<option value="warning">警告</option>
|
||||
<option value="critical">严重</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}> {i18n.t('monitor.logalertfromque.s5ec177f4')} </label>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>持续(秒)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={duration}
|
||||
@@ -200,7 +200,7 @@ export function LogAlertFromQueryDialog({ open, onOpenChange, ctx, onSaved }: Pr
|
||||
style={{ background: 'var(--dg-blue)' }}
|
||||
>
|
||||
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
{saved ? i18n.t('kubernetes.event.created') : i18n.t('monitor.alertruledialog.s8e1e3dea')}
|
||||
{saved ? '已创建' : '创建规则'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Loader2, Plug, RefreshCw } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { getErrorMessage } from '@/lib/utils';
|
||||
import { logCenterApi, type LogDataSource, type TestConnectionResult } from '@/api';
|
||||
import type { LogEngine } from './log-center-types';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
export function LogDatasourceDialog({
|
||||
open, onOpenChange, engine, editing, onSaved,
|
||||
@@ -128,12 +128,12 @@ export function LogDatasourceDialog({
|
||||
: payload();
|
||||
const result: TestConnectionResult = await logCenterApi.testDatasource(data);
|
||||
if (result.status === 'ok') {
|
||||
toast({ title: i18n.t('aiops.llmTestSuccess') });
|
||||
toast({ title: '连接成功' });
|
||||
} else {
|
||||
toast({ title: i18n.t('aiops.llmTestFailed'), description: result.error || i18n.t('monitor.dashboard.agent.toast.unknownError'), variant: 'destructive' });
|
||||
toast({ title: '连接失败', description: result.error || '未知错误', variant: 'destructive' });
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast({ title: i18n.t('aiops.llmTestFailed'), description: e?.message || String(e), variant: 'destructive' });
|
||||
} catch (e: unknown) {
|
||||
toast({ title: '连接失败', description: getErrorMessage(e), variant: 'destructive' });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
@@ -148,11 +148,11 @@ export function LogDatasourceDialog({
|
||||
} else {
|
||||
await logCenterApi.createDatasource(payload());
|
||||
}
|
||||
toast({ title: i18n.t('deploy.toast.saved') });
|
||||
toast({ title: '已保存' });
|
||||
onOpenChange(false);
|
||||
onSaved();
|
||||
} catch (e: any) {
|
||||
toast({ title: i18n.t('kubernetes.msg.saveFailed'), description: e?.message || String(e), variant: 'destructive' });
|
||||
} catch (e: unknown) {
|
||||
toast({ title: '保存失败', description: getErrorMessage(e), variant: 'destructive' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -163,7 +163,7 @@ export function LogDatasourceDialog({
|
||||
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-md max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)]">
|
||||
{editing ? i18n.t('common.edit') : i18n.t('monitor.dashboard.custom.addBtn')}{engine === 'loki' ? ' Loki' : ' Elasticsearch'} 数据源
|
||||
{editing ? '编辑' : '添加'}{engine === 'loki' ? ' Loki' : ' Elasticsearch'} 数据源
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-muted)]">
|
||||
仅保存连接信息,日志本体存储在您的 Loki / ES 集群
|
||||
@@ -171,17 +171,17 @@ export function LogDatasourceDialog({
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-1">
|
||||
<div>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('kubernetes.secret.secretName')} </Label>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">名称</Label>
|
||||
<Input value={name} onChange={e => setName(e.target.value)} className="mt-1 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('monitor.datasourceUrl')} </Label>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">地址</Label>
|
||||
<Input value={url} onChange={e => setUrl(e.target.value)} placeholder="http://host:port" className="mt-1 h-9 font-mono text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
</div>
|
||||
{engine === 'elasticsearch' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('monitor.logdatasourcedi.s6621b501')} </Label>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">默认索引</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -211,7 +211,7 @@ export function LogDatasourceDialog({
|
||||
获取索引
|
||||
</Button>
|
||||
</div>
|
||||
<Input value={defaultIndex} onChange={e => setDefaultIndex(e.target.value)} placeholder={i18n.t('monitor.logdatasourcedi.s6064025e')} className="mt-1 h-9 font-mono text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
<Input value={defaultIndex} onChange={e => setDefaultIndex(e.target.value)} placeholder="索引名或通配符,如 .monitoring-es-7-*" className="mt-1 h-9 font-mono text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
{/* 已获取索引快捷选择(支持通配) // Logan.Li */}
|
||||
{indices.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1 max-h-24 overflow-auto">
|
||||
@@ -246,23 +246,23 @@ export function LogDatasourceDialog({
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('monitor.dashboard.creds.authType')} </Label>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">认证方式</Label>
|
||||
<Select value={authType} onValueChange={(v) => setAuthType(v as LogDataSource['auth_type'])}>
|
||||
<SelectTrigger className="mt-1 h-9 w-full bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<SelectItem value="none" className="text-xs"> {i18n.t('monitor.logdatasourcedi.s7870c3f1')} </SelectItem>
|
||||
<SelectItem value="none" className="text-xs">无认证</SelectItem>
|
||||
<SelectItem value="basic" className="text-xs">Basic Auth</SelectItem>
|
||||
<SelectItem value="bearer" className="text-xs">Bearer Token</SelectItem>
|
||||
<SelectItem value="custom" className="text-xs"> {i18n.t('monitor.logdatasourcedi.s2e722014')} </SelectItem>
|
||||
<SelectItem value="custom" className="text-xs">自定义 Headers</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{authType === 'basic' && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input value={user} onChange={e => setUser(e.target.value)} placeholder={i18n.t('system.userName')} className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
<Input type="password" value={pass} onChange={e => setPass(e.target.value)} placeholder={i18n.t('system.passwordLabel')} className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
<Input value={user} onChange={e => setUser(e.target.value)} placeholder="用户名" className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
<Input type="password" value={pass} onChange={e => setPass(e.target.value)} placeholder="密码" className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
</div>
|
||||
)}
|
||||
{authType === 'bearer' && (
|
||||
@@ -278,23 +278,23 @@ export function LogDatasourceDialog({
|
||||
const next = [...headers];
|
||||
next[i].name = e.target.value;
|
||||
setHeaders(next);
|
||||
}} placeholder={i18n.t('monitor.logdatasourcedi.sc7f8ebda')} className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
}} placeholder="Header 名,如 X-Scope-OrgID" className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
<Input value={h.value} onChange={e => {
|
||||
const next = [...headers];
|
||||
next[i].value = e.target.value;
|
||||
setHeaders(next);
|
||||
}} placeholder={i18n.t('monitor.logdatasourcedi.scf7126fb')} className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
}} placeholder="Header 值" className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
</div>
|
||||
))}
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs border-[var(--dg-border)]" onClick={() => setHeaders([...headers, { name: '', value: '' }])}> {i18n.t('monitor.logdatasourcedi.s86a7a65e')} </Button>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs border-[var(--dg-border)]" onClick={() => setHeaders([...headers, { name: '', value: '' }])}>添加 Header</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('monitor.logdatasourcedi.sa441a86f')} </Label>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">跳过 TLS 校验</Label>
|
||||
<Switch checked={skipTLS} onCheckedChange={setSkipTLS} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('aiops.llmSetDefault')} </Label>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">设为默认</Label>
|
||||
<Switch checked={isDefault} onCheckedChange={setIsDefault} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -125,9 +125,10 @@ export function LogHistogram({ data, engine, onBarClick, loading, totalBytes, li
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
activeDot={{ r: 3, stroke: colors.stroke, strokeWidth: 1 }}
|
||||
onClick={(entry: any) => {
|
||||
if (onBarClick && entry?.timestamp) {
|
||||
const start = entry.timestamp;
|
||||
onClick={(entry: unknown) => {
|
||||
const point = entry as { timestamp?: string };
|
||||
if (onBarClick && point.timestamp) {
|
||||
const start = point.timestamp;
|
||||
const end = start.slice(0, 13) + ':59:59';
|
||||
onBarClick(start, end);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import { useState } from 'react';
|
||||
import { X, Save, BellRing, Loader2 } from 'lucide-react';
|
||||
import { alertApi } from '@/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { getErrorMessage } from '@/lib/utils';
|
||||
import type { LogEngine, TimeRange } from './types-v3';
|
||||
import { ENGINE_META, savedQueryStorage } from './types-v3';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
interface SaveQueryProps {
|
||||
open: boolean;
|
||||
@@ -47,24 +47,24 @@ export function SaveQueryDialog({ open, onClose, engine, query, timeRange, datas
|
||||
<div className="fixed inset-0 z-50" style={{ background: 'rgba(0,0,0,0.5)' }} onClick={onClose} />
|
||||
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-md rounded-lg border shadow-xl overflow-hidden" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: 'var(--dg-border)' }}>
|
||||
<div className="flex items-center gap-2"><Save className="h-4 w-4" style={{ color: 'var(--dg-blue)' }} /><span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}> {i18n.t('monitor.statChart.saveAsQuery')} </span></div>
|
||||
<div className="flex items-center gap-2"><Save className="h-4 w-4" style={{ color: 'var(--dg-blue)' }} /><span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>另存为查询</span></div>
|
||||
<button onClick={onClose} className="h-7 w-7 flex items-center justify-center" style={{ color: 'var(--dg-text-muted)' }}><X className="h-4 w-4" /></button>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<div>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}> {i18n.t('monitor.savequerydialog.s54c06867')} </label>
|
||||
<input value={name} onChange={e => setName(e.target.value)} placeholder={i18n.t('monitor.savequerydialog.s30168e0e')} className="w-full h-9 px-3 text-xs rounded" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }} />
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>查询名称 *</label>
|
||||
<input value={name} onChange={e => setName(e.target.value)} placeholder="输入查询名称" className="w-full h-9 px-3 text-xs rounded" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }} />
|
||||
</div>
|
||||
<div className="p-2 rounded text-[11px]" style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-secondary)' }}>
|
||||
<span style={{ color: ENGINE_META[engine].color }}>{ENGINE_META[engine].name}</span> · {datasourceName || i18n.t('k8s.storage.default')}<br />
|
||||
<span style={{ color: ENGINE_META[engine].color }}>{ENGINE_META[engine].name}</span> · {datasourceName || '默认'}<br />
|
||||
<span className="font-mono">{query}</span><br />
|
||||
<span style={{ color: 'var(--dg-text-muted)' }}>{timeRange.label} · {timeRange.start} ~ {timeRange.end}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t" style={{ borderColor: 'var(--dg-border)' }}>
|
||||
<button onClick={onClose} className="h-8 px-3 rounded text-xs" style={{ color: 'var(--dg-text-secondary)', border: '1px solid var(--dg-border)', background: 'var(--dg-bg-tertiary)' }}> {i18n.t('rbac.jobRole.cancel')} </button>
|
||||
<button onClick={onClose} className="h-8 px-3 rounded text-xs" style={{ color: 'var(--dg-text-secondary)', border: '1px solid var(--dg-border)', background: 'var(--dg-bg-tertiary)' }}>取消</button>
|
||||
<button onClick={handleSave} disabled={saving || !name.trim() || saved} className="h-8 px-4 rounded text-xs font-medium text-white flex items-center gap-1" style={{ background: 'var(--dg-blue)' }}>
|
||||
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}{saved ? i18n.t('deploy.toast.saved') : i18n.t('task.script.save')}
|
||||
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}{saved ? '已保存' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,7 +101,7 @@ export function SaveAlertDialog({ open, onClose, engine, query, datasourceId, da
|
||||
try {
|
||||
await alertApi.createRule({
|
||||
name: name.trim(),
|
||||
description: `从查询页创建: ${datasourceName || i18n.t('monitor.savequerydialog.sddb4ca92')}`,
|
||||
description: `从查询页创建: ${datasourceName || '默认数据源'}`,
|
||||
type: 'log',
|
||||
source: engine === 'sls' ? 'loki' : engine,
|
||||
expression: query.trim(),
|
||||
@@ -111,11 +111,11 @@ export function SaveAlertDialog({ open, onClose, engine, query, datasourceId, da
|
||||
notify_channels: [],
|
||||
});
|
||||
setSaved(true);
|
||||
toast({ title: i18n.t('monitor.savequerydialog.sd3dbea92') });
|
||||
toast({ title: '告警规则已创建' });
|
||||
onSave?.();
|
||||
setTimeout(() => { setSaved(false); onClose(); }, 800);
|
||||
} catch (e: any) {
|
||||
toast({ title: i18n.t('monitor.dashboard.custom.toastCreateFailed'), description: e?.message || String(e), variant: 'destructive' });
|
||||
} catch (e: unknown) {
|
||||
toast({ title: '创建失败', description: getErrorMessage(e), variant: 'destructive' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -126,35 +126,35 @@ export function SaveAlertDialog({ open, onClose, engine, query, datasourceId, da
|
||||
<div className="fixed inset-0 z-50" style={{ background: 'rgba(0,0,0,0.5)' }} onClick={onClose} />
|
||||
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-md rounded-lg border shadow-xl overflow-hidden" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: 'var(--dg-border)' }}>
|
||||
<div className="flex items-center gap-2"><BellRing className="h-4 w-4" style={{ color: '#f59e0b' }} /><span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}> {i18n.t('monitor.logSaveAsAlert')} </span></div>
|
||||
<div className="flex items-center gap-2"><BellRing className="h-4 w-4" style={{ color: '#f59e0b' }} /><span className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>另存为告警</span></div>
|
||||
<button onClick={onClose} className="h-7 w-7 flex items-center justify-center" style={{ color: 'var(--dg-text-muted)' }}><X className="h-4 w-4" /></button>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<div>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}> {i18n.t('monitor.savequerydialog.s78e82a11')} </label>
|
||||
<input value={name} onChange={e => setName(e.target.value)} placeholder={i18n.t('monitor.savequerydialog.sa3a8d765')} className="w-full h-9 px-3 text-xs rounded" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }} />
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>告警名称 *</label>
|
||||
<input value={name} onChange={e => setName(e.target.value)} placeholder="输入告警规则名称" className="w-full h-9 px-3 text-xs rounded" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}> {i18n.t('kubernetes.event.eventLevel')} </label>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>级别</label>
|
||||
<select value={severity} onChange={e => setSeverity(e.target.value)} className="w-full h-9 px-2 text-xs rounded" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }}>
|
||||
<option value="info"> {i18n.t('monitor.alertEvents.severity.info')} </option><option value="warning"> {i18n.t('kubernetes.event.warning')} </option><option value="critical"> {i18n.t('monitor.alertHelper.sevCritical')} </option>
|
||||
<option value="info">提示</option><option value="warning">警告</option><option value="critical">严重</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}> {i18n.t('monitor.alertrulestabts.s65939ccd')} </label>
|
||||
<label className="text-[10px] block mb-1" style={{ color: 'var(--dg-text-muted)' }}>触发条件</label>
|
||||
<input value={condition} onChange={e => setCondition(e.target.value)} className="w-full h-9 px-2 text-xs rounded" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 rounded text-[11px]" style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-secondary)' }}>
|
||||
<span style={{ color: meta.color }}>{meta.name}</span> · {datasourceName || i18n.t('k8s.storage.default')}<br />
|
||||
<span style={{ color: meta.color }}>{meta.name}</span> · {datasourceName || '默认'}<br />
|
||||
<span className="font-mono">{query}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t" style={{ borderColor: 'var(--dg-border)' }}>
|
||||
<button onClick={onClose} className="h-8 px-3 rounded text-xs" style={{ color: 'var(--dg-text-secondary)', border: '1px solid var(--dg-border)', background: 'var(--dg-bg-tertiary)' }}> {i18n.t('rbac.jobRole.cancel')} </button>
|
||||
<button onClick={onClose} className="h-8 px-3 rounded text-xs" style={{ color: 'var(--dg-text-secondary)', border: '1px solid var(--dg-border)', background: 'var(--dg-bg-tertiary)' }}>取消</button>
|
||||
<button onClick={handleSave} disabled={saving || !name.trim() || saved} className="h-8 px-4 rounded text-xs font-medium text-white flex items-center gap-1" style={{ background: '#f59e0b' }}>
|
||||
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}{saved ? i18n.t('kubernetes.event.created') : i18n.t('monitor.savequerydialog.sbc1d9937')}
|
||||
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}{saved ? '已创建' : '创建告警'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, AreaChart, Area, X
|
||||
import { Save, Play, AlertCircle } from 'lucide-react';
|
||||
import type { StatTable, ChartType, LogEngine } from './types-v3';
|
||||
import { ENGINE_META } from './types-v3';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
interface Props {
|
||||
statTable?: StatTable;
|
||||
@@ -69,7 +70,7 @@ export function StatChartPanel({ statTable, engine, query, onSaveAsAlert, onSave
|
||||
const chartData = useMemo(() => {
|
||||
if (!rows.length) return [];
|
||||
return rows.map(r => {
|
||||
const obj: Record<string, any> = {};
|
||||
const obj: JsonData = {};
|
||||
columns.forEach(c => { obj[c.name] = r[c.name]; });
|
||||
return obj;
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// UnifiedQueryPanel.tsx — 统一查询面板(接入真实 API)// Logan.Li
|
||||
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
||||
import { useSearchParams, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Play, Loader2, Clock, Save, ChevronDown, Bell, Link2, Bookmark, Radio } from 'lucide-react';
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { TimeRangePicker } from './TimeRangePicker';
|
||||
@@ -26,6 +25,7 @@ import { applyESFieldFilter, applyLokiLabelFilter, extractLokiSelector } from '.
|
||||
import type { LogEngine, TimeRange, ResultTab, ViewMode, LogEntry, LogField, StatTable, LogCluster, QueryHistoryItem, SavedQuery } from './types-v3';
|
||||
import { ENGINE_META, buildStatTableFromEntries, buildClustersFromEntries, extractFields, generateHistogramData, historyStorage } from './types-v3';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { getErrorMessage } from '@/lib/utils';
|
||||
|
||||
// 默认最近5分钟 // Logan.Li
|
||||
const DEFAULT_TIME_RANGE: TimeRange = {
|
||||
@@ -51,7 +51,6 @@ const ENGINE_BADGE: Record<'loki' | 'elasticsearch', { label: string; dotColor:
|
||||
interface Props { engine: LogEngine; onRuleCreated: () => void; }
|
||||
|
||||
export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
const { t } = useTranslation(['monitor', 'common']);
|
||||
const { toast } = useToast();
|
||||
const location = useLocation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -76,7 +75,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
const [esIndex, setEsIndex] = useState('');
|
||||
// API 单次查询返回条数上限(后端限制 1000),Loki 用 limit / ES 用 size // Logan.Li
|
||||
const [queryLimit, setQueryLimit] = useState(1000);
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>(() => ({ ...DEFAULT_TIME_RANGE, label: t('monitor:logPanel.last5min') }));
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>(DEFAULT_TIME_RANGE);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [entries, setEntries] = useState<LogEntry[]>([]);
|
||||
const [statTable, setStatTable] = useState<StatTable | undefined>();
|
||||
@@ -264,12 +263,12 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
setPage(1);
|
||||
setHasQueried(true);
|
||||
historyStorage.add({ engine: effEngine as LogEngine, datasourceName: currentSource.name, query, timeRange: timeRange.label, resultCount: mapped.length });
|
||||
} catch (e: any) {
|
||||
const msg = e?.message || String(e);
|
||||
const title = msg.includes('401') || msg.includes('403') ? t('monitor:logPanel.authFailed')
|
||||
: msg.includes('404') ? t('monitor:logPanel.apiNotFound')
|
||||
: msg.includes('无法连接') || msg.includes('timeout') ? t('monitor:logPanel.connFailed')
|
||||
: t('monitor:logPanel.queryFailed');
|
||||
} catch (e: unknown) {
|
||||
const msg = getErrorMessage(e);
|
||||
const title = msg.includes('401') || msg.includes('403') ? '认证失败'
|
||||
: msg.includes('404') ? '接口不存在'
|
||||
: msg.includes('无法连接') || msg.includes('timeout') ? '连接失败'
|
||||
: '查询失败';
|
||||
toast({ title, description: msg, variant: 'destructive' });
|
||||
setEntries([]); setTotalCount(0); setHasQueried(true);
|
||||
} finally {
|
||||
@@ -304,7 +303,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
setNextCursor(data?.next_cursor || '');
|
||||
setSearchAfter(data?.search_after);
|
||||
} catch (e: unknown) {
|
||||
toast({ title: t('monitor:logPanel.loadMoreFailed'), description: (e as Error)?.message, variant: 'destructive' });
|
||||
toast({ title: '加载更多失败', description: (e as Error)?.message, variant: 'destructive' });
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
@@ -344,8 +343,8 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
const full = `${window.location.origin}${url}`;
|
||||
const ok = await copyToClipboard(full);
|
||||
toast({
|
||||
title: ok ? t('monitor:logPanel.shareCopied') : t('monitor:logPanel.shareCopyFailed'),
|
||||
description: ok ? t('monitor:logPanel.shareCopiedDesc') : undefined,
|
||||
title: ok ? '分享链接已复制' : '复制失败',
|
||||
description: ok ? '收件人打开链接将自动执行查询' : undefined,
|
||||
variant: ok ? 'default' : 'destructive',
|
||||
});
|
||||
}, [location.pathname, query, dsId, esIndex, timeRange, toast]);
|
||||
@@ -410,37 +409,44 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
const mappingFieldNames = useMemo(() => indexedFields.map((f) => f.name), [indexedFields]);
|
||||
|
||||
const toggleField = useCallback((n: string) => setSelectedFields(p => p.includes(n) ? p.filter(f => f !== n) : [...p, n]), []);
|
||||
const handleSaveMenu = (a: 'query' | 'alert') => { setSaveMenuOpen(false); a === 'query' ? setSaveQueryOpen(true) : setSaveAlertOpen(true); };
|
||||
const handleSaveMenu = (a: 'query' | 'alert') => {
|
||||
setSaveMenuOpen(false);
|
||||
if (a === 'query') {
|
||||
setSaveQueryOpen(true);
|
||||
} else {
|
||||
setSaveAlertOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChartClick = useCallback((st: any) => {
|
||||
if (!st?.activeLabel) return;
|
||||
const handleChartClick = useCallback((st: { activeLabel?: string }) => {
|
||||
if (!st.activeLabel) return;
|
||||
const idx = histogramData.findIndex(d => d.timestamp === st.activeLabel);
|
||||
if (idx < 0) return;
|
||||
const sIdx = Math.max(0, idx - 1), eIdx = Math.min(histogramData.length - 1, idx + 1);
|
||||
setTimeRange(p => ({ ...p, start: histogramData[sIdx].timestamp, end: histogramData[eIdx].timestamp, label: t('monitor:logPanel.custom'), type: 'absolute' as const }));
|
||||
setTimeRange(p => ({ ...p, start: histogramData[sIdx].timestamp, end: histogramData[eIdx].timestamp, label: '自定义', type: 'absolute' as const }));
|
||||
}, [histogramData]);
|
||||
|
||||
const formatEntryLine = useCallback((e: LogEntry) => buildLogCopyText(e), []);
|
||||
|
||||
const handleExportLogs = useCallback(() => {
|
||||
const logText = sortedEntries.map(formatEntryLine).join('\n');
|
||||
const b = new Blob([logText], { type: 'text/plain;charset=utf-8' });
|
||||
const t = sortedEntries.map(formatEntryLine).join('\n');
|
||||
const b = new Blob([t], { type: 'text/plain;charset=utf-8' });
|
||||
const u = URL.createObjectURL(b);
|
||||
const a = document.createElement('a');
|
||||
a.href = u;
|
||||
a.download = `${effEngine}-logs-${Date.now()}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(u);
|
||||
toast({ title: t('monitor:logPanel.exported'), description: t('monitor:logPanel.exportedDesc', { count: sortedEntries.length }) });
|
||||
toast({ title: '已导出', description: `${sortedEntries.length} 条日志` });
|
||||
}, [sortedEntries, effEngine, formatEntryLine, toast]);
|
||||
|
||||
const handleCopyLogs = useCallback(async () => {
|
||||
const logText = pagedEntries.map(formatEntryLine).join('\n');
|
||||
const ok = await copyToClipboard(logText);
|
||||
const text = pagedEntries.map(formatEntryLine).join('\n');
|
||||
const ok = await copyToClipboard(text);
|
||||
if (ok) {
|
||||
toast({ title: t('monitor:logPanel.copied'), description: t('monitor:logPanel.copiedDesc', { count: pagedEntries.length }) });
|
||||
toast({ title: '已复制', description: `当前页 ${pagedEntries.length} 条完整日志` });
|
||||
} else {
|
||||
toast({ title: t('monitor:logPanel.copyFailed'), description: t('monitor:logPanel.copyFailedDesc'), variant: 'destructive' });
|
||||
toast({ title: '复制失败', description: '请使用导出或展开单条复制', variant: 'destructive' });
|
||||
}
|
||||
}, [pagedEntries, formatEntryLine, toast]);
|
||||
|
||||
@@ -464,7 +470,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
onRequestLabelValues={handleRequestLabelValues}
|
||||
/>
|
||||
<button onClick={() => { setLiveTail(false); void handleRun(); }} disabled={loading || liveTail || !currentSource || !query.trim()} className="h-10 px-4 rounded text-xs font-medium text-white flex items-center gap-1.5 flex-shrink-0 transition-opacity hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed" style={{ background: '#22c55e' }}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />} {t('monitor:logPanel.query')}
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />} 查询
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLiveTail((v) => !v)}
|
||||
@@ -475,7 +481,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
color: liveTail ? '#ef4444' : 'var(--dg-text-secondary)',
|
||||
background: liveTail ? 'rgba(239,68,68,0.1)' : 'var(--dg-bg-tertiary)',
|
||||
}}
|
||||
title={t('monitor:logPanel.liveTailTitle')}
|
||||
title="Live Tail — 每 3 秒刷新最近 5 分钟日志"
|
||||
>
|
||||
<Radio className={`h-3.5 w-3.5 ${liveTail ? 'animate-pulse' : ''}`} /> Tail
|
||||
</button>
|
||||
@@ -486,8 +492,8 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
<TimeRangePicker value={timeRange} onChange={setTimeRange} />
|
||||
|
||||
<select value={dsId} onChange={e => setDsId(e.target.value)} className="h-10 px-2 text-xs rounded flex-shrink-0" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)', color: 'var(--dg-text-primary)' }}>
|
||||
{sources.length === 0 && <option value="">{t('monitor:logPanel.noDatasource', { name: meta.name })}</option>}
|
||||
{sources.map(s => <option key={s.id} value={s.id}>{s.name}{s.is_default ? t('monitor:logPanel.defaultSuffix') : ''}</option>)}
|
||||
{sources.length === 0 && <option value="">无可用{meta.name}数据源</option>}
|
||||
{sources.map(s => <option key={s.id} value={s.id}>{s.name}{s.is_default ? ' (默认)' : ''}</option>)}
|
||||
</select>
|
||||
|
||||
{effEngine === 'elasticsearch' && (
|
||||
@@ -499,7 +505,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 h-10 px-2 rounded flex-shrink-0" style={{ background: 'var(--dg-bg-tertiary)', border: '1px solid var(--dg-border)' }}>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">{t('monitor:logPanel.limit')}</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">上限</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -516,24 +522,24 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
|
||||
<div className="relative flex-shrink-0">
|
||||
<button onClick={() => setSaveMenuOpen(!saveMenuOpen)} className="h-10 px-3 rounded text-xs flex items-center gap-1" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-secondary)', background: 'var(--dg-bg-tertiary)' }}>
|
||||
<Save className="h-3.5 w-3.5" /> {t('monitor:logPanel.saveAs')} <ChevronDown className="h-3 w-3" />
|
||||
<Save className="h-3.5 w-3.5" /> 另存为 <ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
{saveMenuOpen && (<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setSaveMenuOpen(false)} />
|
||||
<div className="absolute right-0 top-full mt-1 z-50 rounded-lg border shadow-xl overflow-hidden" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
|
||||
<button onClick={() => handleSaveMenu('query')} className="w-full text-left px-4 py-2 text-xs flex items-center gap-2 hover:opacity-80" style={{ color: 'var(--dg-text-secondary)' }}><Save className="h-3.5 w-3.5" /> {t('monitor:logPanel.saveAsQueryItem')}</button>
|
||||
<button onClick={() => handleSaveMenu('alert')} className="w-full text-left px-4 py-2 text-xs flex items-center gap-2 hover:opacity-80" style={{ color: 'var(--dg-text-secondary)', borderTop: '1px solid var(--dg-border)' }}><Bell className="h-3.5 w-3.5" /> {t('monitor:logPanel.saveAsAlertItem')}</button>
|
||||
<button onClick={() => handleSaveMenu('query')} className="w-full text-left px-4 py-2 text-xs flex items-center gap-2 hover:opacity-80" style={{ color: 'var(--dg-text-secondary)' }}><Save className="h-3.5 w-3.5" /> 查询</button>
|
||||
<button onClick={() => handleSaveMenu('alert')} className="w-full text-left px-4 py-2 text-xs flex items-center gap-2 hover:opacity-80" style={{ color: 'var(--dg-text-secondary)', borderTop: '1px solid var(--dg-border)' }}><Bell className="h-3.5 w-3.5" /> 告警</button>
|
||||
</div>
|
||||
</>)}
|
||||
</div>
|
||||
|
||||
<button onClick={() => { void handleShareLink(); }} className="h-10 px-2.5 flex items-center gap-1 rounded flex-shrink-0 text-xs" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }} title={t('monitor:logPanel.shareLinkTitle')}>
|
||||
<Link2 className="h-3.5 w-3.5" /> {t('monitor:logPanel.share')}
|
||||
<button onClick={() => { void handleShareLink(); }} className="h-10 px-2.5 flex items-center gap-1 rounded flex-shrink-0 text-xs" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }} title="复制分享链接">
|
||||
<Link2 className="h-3.5 w-3.5" /> 分享
|
||||
</button>
|
||||
<button onClick={() => setSavedPanelOpen(true)} className="h-10 w-10 flex items-center justify-center rounded flex-shrink-0" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }} title={t('monitor:logPanel.savedQueries')}>
|
||||
<button onClick={() => setSavedPanelOpen(true)} className="h-10 w-10 flex items-center justify-center rounded flex-shrink-0" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }} title="已保存查询">
|
||||
<Bookmark className="h-4 w-4" />
|
||||
</button>
|
||||
<button onClick={() => setHistoryPanelOpen(true)} className="h-10 w-10 flex items-center justify-center rounded flex-shrink-0" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }} title={t('monitor:logPanel.history')}>
|
||||
<button onClick={() => setHistoryPanelOpen(true)} className="h-10 w-10 flex items-center justify-center rounded flex-shrink-0" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }} title="查询历史">
|
||||
<Clock className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -541,7 +547,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
{/* 无数据源时引导用户前往日志库配置 // Logan.Li */}
|
||||
{sources.length === 0 && (
|
||||
<div className="p-3 rounded border text-xs" style={{ background: 'rgba(245,158,11,0.1)', borderColor: 'rgba(245,158,11,0.3)', color: '#f59e0b' }}>
|
||||
{t('monitor:logPanel.noDatasourceHint', { name: meta.name })}
|
||||
暂无{meta.name}数据源,请先前往「日志库」添加数据源。
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -576,7 +582,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" vertical={false} />
|
||||
<XAxis dataKey="timestamp" tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} axisLine={{ stroke: 'var(--dg-border)' }} tickLine={false} tickFormatter={formatHistogramTick} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fontSize: 9, fill: 'var(--dg-text-muted)' }} axisLine={false} tickLine={false} width={36} tickFormatter={formatHistogramCount} />
|
||||
<Tooltip contentStyle={{ background: 'var(--dg-bg-secondary)', border: '1px solid var(--dg-border)', borderRadius: '6px', fontSize: '11px', color: 'var(--dg-text-primary)' }} formatter={(v: number) => [`${v} ${t('monitor:logPanel.chartCountSuffix')}`, t('monitor:logPanel.chartCountLabel')]} labelFormatter={(l: string) => `${t('monitor:logPanel.chartTimePrefix')} ${l}`} />
|
||||
<Tooltip contentStyle={{ background: 'var(--dg-bg-secondary)', border: '1px solid var(--dg-border)', borderRadius: '6px', fontSize: '11px', color: 'var(--dg-text-primary)' }} formatter={(v: number) => [`${v} 条日志`, '数量']} labelFormatter={(l: string) => `时间: ${l}`} />
|
||||
<Area type="monotone" dataKey="count" stroke={meta.color} fill={`url(#vol-${effEngine})`} strokeWidth={1.5} dot={false} activeDot={{ r: 4, stroke: meta.color, strokeWidth: 2, fill: 'var(--dg-bg-secondary)' }} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -588,7 +594,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
<div className="rounded-lg border" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
|
||||
<div className="flex items-center gap-0.5 px-2 pt-1.5" style={{ borderBottom: '1px solid var(--dg-border)' }}>
|
||||
{(['logs', 'stats', 'cluster'] as ResultTab[]).map(tab => {
|
||||
const labels: Record<ResultTab, string> = { logs: t('monitor:logPanel.tabLogs'), stats: t('monitor:logPanel.tabStats'), cluster: t('monitor:logPanel.tabCluster') };
|
||||
const labels: Record<ResultTab, string> = { logs: '原始日志', stats: '统计图表', cluster: '日志聚类' };
|
||||
return (
|
||||
<button key={tab} onClick={() => setResultTab(tab)} className="px-3 py-1.5 text-[11px] font-medium rounded-t-md transition-colors"
|
||||
style={{ color: resultTab === tab ? meta.color : 'var(--dg-text-muted)', background: resultTab === tab ? 'var(--dg-bg-secondary)' : 'transparent', borderBottom: resultTab === tab ? `2px solid ${meta.color}` : '2px solid transparent', marginBottom: '-1px' }}>
|
||||
@@ -610,12 +616,12 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
<div className="flex rounded border overflow-hidden" style={{ borderColor: 'var(--dg-border)' }}>
|
||||
{(['raw', 'field', 'table'] as const).map(m => (
|
||||
<button key={m} onClick={() => setViewMode(m)} className="px-3 py-1 text-[11px] transition-colors" style={{ background: viewMode === m ? meta.color : 'var(--dg-bg-tertiary)', color: viewMode === m ? '#fff' : 'var(--dg-text-muted)' }}>
|
||||
{m === 'raw' ? t('monitor:logPanel.viewRaw') : m === 'field' ? t('monitor:logPanel.viewField') : t('monitor:logPanel.viewTable')}
|
||||
{m === 'raw' ? '原始日志' : m === 'field' ? '字段视图' : '表格视图'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }} title={t('monitor:logPanel.truncateTitle')}><input type="checkbox" checked={truncate} onChange={e => setTruncate(e.target.checked)} className="h-3.5 w-3.5 rounded" /> {t('monitor:logPanel.truncate')}</label>
|
||||
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }} title={t('monitor:logPanel.wrapLinesTitle')}><input type="checkbox" checked={wrapLines} onChange={e => setWrapLines(e.target.checked)} className="h-3.5 w-3.5 rounded" /> {t('monitor:logPanel.wrapLines')}</label>
|
||||
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }} title="开启后折叠行仅显示前 500 字预览"><input type="checkbox" checked={truncate} onChange={e => setTruncate(e.target.checked)} className="h-3.5 w-3.5 rounded" /> 截断预览</label>
|
||||
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }} title="长日志自动换行显示"><input type="checkbox" checked={wrapLines} onChange={e => setWrapLines(e.target.checked)} className="h-3.5 w-3.5 rounded" /> 换行</label>
|
||||
</div>
|
||||
</>)}
|
||||
{resultTab === 'stats' && <StatChartPanel statTable={statTable} engine={effEngine as LogEngine} query={query} onSaveAsAlert={() => setSaveAlertOpen(true)} onSaveAsQuery={() => setSaveQueryOpen(true)} />}
|
||||
@@ -627,7 +633,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
{/* 空状态:从未查询 vs 查询返回 0 结果 */}
|
||||
{!loading && entries.length === 0 && !hasQueried && (
|
||||
<div className="border rounded-lg flex flex-col items-center justify-center gap-2" style={{ borderColor: 'var(--dg-border)', minHeight: '300px' }}>
|
||||
<span className="text-sm" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor:logPanel.emptyHint')}</span>
|
||||
<span className="text-sm" style={{ color: 'var(--dg-text-muted)' }}>输入查询语句并点击「查询」查看结果</span>
|
||||
<span className="text-xs font-mono px-3 py-1 rounded" style={{ color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }}>{meta.queryPlaceholder}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -639,13 +645,13 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>{t('monitor:logPanel.noMatch')}</p>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--dg-text-primary)' }}>未找到匹配的日志记录</p>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--dg-text-muted)' }}>
|
||||
{t('monitor:logPanel.noMatchDesc', { ms: queryTime })}
|
||||
查询已执行({queryTime}ms),但当前时间范围和筛选条件下无匹配结果
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={handleRun} className="px-4 py-1.5 rounded text-xs font-medium text-white transition-opacity hover:opacity-90" style={{ background: '#22c55e' }}>
|
||||
<Play className="h-3.5 w-3.5 inline mr-1" />{t('monitor:logPanel.requeries')}
|
||||
<Play className="h-3.5 w-3.5 inline mr-1" />重新查询
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -77,7 +77,7 @@ export function LogRoleCreateDialog({
|
||||
: t('rbac.logRole.patternHint.labelRegex', { field: matchField });
|
||||
}
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
|
||||
new RegExp(pattern);
|
||||
return preview.length > 0
|
||||
? t('rbac.logRole.patternHint.matched', { count: preview.length })
|
||||
|
||||
@@ -55,7 +55,7 @@ export function buildLokiSelector(labels: Record<string, string>): string {
|
||||
}
|
||||
|
||||
export function mergeLokiSelector(query: string, labels: Record<string, string>): string {
|
||||
let q = query.trim();
|
||||
const q = query.trim();
|
||||
const selector = buildLokiSelector(labels);
|
||||
if (LOKI_SELECTOR_RE.test(q)) {
|
||||
return q.replace(LOKI_SELECTOR_RE, selector);
|
||||
|
||||
@@ -6,7 +6,7 @@ export function highlightSyntax(query: string, engine: 'loki' | 'elasticsearch')
|
||||
const span = (color: string, text: string) => `<span style="color:${color}">${text}</span>`;
|
||||
if (engine === 'loki') {
|
||||
return esc.replace(
|
||||
/("(?:[^"\\]|\\.)*")|([a-zA-Z_][\w-]*)(?==)|(\|json|\|regexp|\|line_format|\|unwrap|\|pattern|\|label_format)|\b(count_over_time|sum|rate|avg|max|min|count|by|over)\b|([{}])|(\|=|\!=|=~|!~|=)/g,
|
||||
/("(?:[^"\\]|\\.)*")|([a-zA-Z_][\w-]*)(?==)|(\|json|\|regexp|\|line_format|\|unwrap|\|pattern|\|label_format)|\b(count_over_time|sum|rate|avg|max|min|count|by|over)\b|([{}])|(\|=|!=|=~|!~|=)/g,
|
||||
(m, str, ident, pipe, fn, brace, op) => {
|
||||
if (str) return span('#22c55e', str);
|
||||
if (ident) return span('#f59e0b', ident);
|
||||
@@ -37,4 +37,4 @@ export const ES_KEYWORD_SUGGESTIONS = ['AND ', 'OR ', 'NOT '];
|
||||
export const COMMON_ES_FIELDS = [
|
||||
'message', 'level', 'log.level', 'app', 'service', 'host.name', '@timestamp',
|
||||
'kubernetes.namespace', 'trace_id', 'status', 'method', 'path',
|
||||
];
|
||||
];
|
||||
@@ -1,5 +1,6 @@
|
||||
import i18n from '@/i18n';
|
||||
// types-v3.ts — v3 统一日志中心类型定义 // Logan.Li
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
export type LogEngine = 'loki' | 'elasticsearch' | 'sls';
|
||||
export type ViewMode = 'raw' | 'field' | 'table';
|
||||
@@ -33,7 +34,7 @@ export interface LogField {
|
||||
// 统计表格 // Logan.Li
|
||||
export interface StatTable {
|
||||
columns: StatColumn[];
|
||||
rows: Record<string, any>[];
|
||||
rows: JsonData[];
|
||||
}
|
||||
|
||||
export interface StatColumn {
|
||||
|
||||
Reference in new issue
Block a user