feat: 新增日志中心、功能优化与代码重构
- 新增 log_datasource.go / log_proxy.go / user_api_token.go 后端控制器 - 新增 log_datasource.go model - 新增 monitor/log-center.ts API 模块 - 新增 LogCenter 前端组件及页面 - 重构 deployment 相关组件 (HPDA/UpdateImageModal/bundle-yaml 等) - 新增 ResetPasswordDialog / code-snippet-block / copy-button 等 UI 组件 - 重构多个 k8s 相关组件 (TopologyView/ResourceVisualEditor/PodFileBrowser 等) - 更新多个 API 和工具函数 (auth.ts/monitor/index.ts/request.ts 等) - 更新静态资源 (index.html + 新 JS/CSS bundle) - 删除废弃静态资源文件
This commit is contained in:
1 parent
5ca0e6d83a
commit
8d46e8f352
197 files changed
+5014
-815
No files matched your search
@@ -33,12 +33,15 @@ export function AlertRuleDialog({
|
||||
editingRule,
|
||||
notifyChannels,
|
||||
onSaved,
|
||||
defaultType = 'metric',
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editingRule: any;
|
||||
notifyChannels: any[];
|
||||
onSaved: () => void;
|
||||
/** 新建时默认规则类型 // Logan.Li */
|
||||
defaultType?: 'metric' | 'log' | 'event';
|
||||
}) {
|
||||
const [ruleForm, setRuleForm] = useState({ ...emptyRuleForm });
|
||||
const [ruleSaving, setRuleSaving] = useState(false);
|
||||
@@ -58,9 +61,13 @@ export function AlertRuleDialog({
|
||||
notify_channels: parseChannels(editingRule.notify_channels),
|
||||
});
|
||||
} else {
|
||||
setRuleForm({ ...emptyRuleForm });
|
||||
const base = { ...emptyRuleForm, type: defaultType };
|
||||
if (defaultType === 'log') {
|
||||
base.source = 'loki';
|
||||
}
|
||||
setRuleForm(base);
|
||||
}
|
||||
}, [open, editingRule]);
|
||||
}, [open, editingRule, defaultType]);
|
||||
|
||||
const toggleNotifyChannel = (channel: string) => {
|
||||
setRuleForm((prev) => ({
|
||||
@@ -138,7 +145,11 @@ export function AlertRuleDialog({
|
||||
<Label className="text-slate-300">规则类型</Label>
|
||||
<select
|
||||
value={ruleForm.type}
|
||||
onChange={(e) => setRuleForm({ ...ruleForm, type: e.target.value })}
|
||||
onChange={(e) => {
|
||||
const type = e.target.value;
|
||||
const source = type === 'log' ? 'loki' : 'prometheus';
|
||||
setRuleForm({ ...ruleForm, type, source });
|
||||
}}
|
||||
className="w-full h-9 px-3 rounded-md bg-slate-800/50 border border-slate-700 text-slate-200 text-sm"
|
||||
>
|
||||
<option value="metric">指标监控</option>
|
||||
@@ -153,9 +164,18 @@ export function AlertRuleDialog({
|
||||
onChange={(e) => setRuleForm({ ...ruleForm, source: e.target.value })}
|
||||
className="w-full h-9 px-3 rounded-md bg-slate-800/50 border border-slate-700 text-slate-200 text-sm"
|
||||
>
|
||||
<option value="prometheus">Prometheus</option>
|
||||
<option value="zabbix">Zabbix</option>
|
||||
<option value="custom">自定义</option>
|
||||
{ruleForm.type === 'log' ? (
|
||||
<>
|
||||
<option value="loki">Loki</option>
|
||||
<option value="elasticsearch">Elasticsearch</option>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<option value="prometheus">Prometheus</option>
|
||||
<option value="zabbix">Zabbix</option>
|
||||
<option value="custom">自定义</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -165,10 +185,14 @@ export function AlertRuleDialog({
|
||||
<Input
|
||||
value={ruleForm.expression}
|
||||
onChange={(e) => setRuleForm({ ...ruleForm, expression: e.target.value })}
|
||||
placeholder="如:cpu_usage > 80"
|
||||
placeholder={ruleForm.type === 'log' ? '{app="api"} |= "error"' : 'cpu_usage > 80'}
|
||||
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600 font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-slate-600">PromQL 或指标表达式,例如 cpu_usage > 80</p>
|
||||
<p className="text-xs text-slate-600">
|
||||
{ruleForm.type === 'log'
|
||||
? (ruleForm.source === 'loki' ? 'LogQL 查询或 count_over_time 表达式' : 'Elasticsearch Lucene 查询')
|
||||
: 'PromQL 或指标表达式,例如 cpu_usage > 80'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// EsQueryPanel — Elasticsearch 查询 // Logan.Li
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Play, Plus, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { logCenterApi, type LogDataSource, type LogQueryEntry } from '@/api/monitor/log-center';
|
||||
import { LogDatasourceDialog } from './LogDatasourceDialog';
|
||||
import { LogResultsTable } from './LogResultsTable';
|
||||
import { useLogQueryContext } from './LogQueryContext';
|
||||
|
||||
const TIME_PRESETS = [
|
||||
{ label: '15 分钟', value: '15m' },
|
||||
{ label: '1 小时', value: '1h' },
|
||||
{ label: '6 小时', value: '6h' },
|
||||
{ label: '24 小时', value: '24h' },
|
||||
];
|
||||
|
||||
function presetToStart(preset: string): string {
|
||||
const m = preset.match(/^(\d+)(m|h)$/);
|
||||
if (!m) return new Date(Date.now() - 3600000).toISOString();
|
||||
const n = parseInt(m[1], 10);
|
||||
const ms = m[2] === 'h' ? n * 3600000 : n * 60000;
|
||||
return new Date(Date.now() - ms).toISOString();
|
||||
}
|
||||
|
||||
export function EsQueryPanel() {
|
||||
const { toast } = useToast();
|
||||
const { setContext } = useLogQueryContext();
|
||||
const [sources, setSources] = useState<LogDataSource[]>([]);
|
||||
const [dsId, setDsId] = useState<string>('');
|
||||
const [index, setIndex] = useState('logstash-*');
|
||||
const [query, setQuery] = useState('level:error AND message:*timeout*');
|
||||
const [timePreset, setTimePreset] = useState('1h');
|
||||
const [limit, setLimit] = useState(200);
|
||||
const [entries, setEntries] = useState<LogQueryEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dsDialog, setDsDialog] = useState(false);
|
||||
|
||||
const loadSources = useCallback(async () => {
|
||||
const list = await logCenterApi.listDatasources('elasticsearch').catch(() => []);
|
||||
const arr = Array.isArray(list) ? list : [];
|
||||
setSources(arr);
|
||||
if (!dsId && arr.length) {
|
||||
const def = arr.find(s => s.is_default) || arr[0];
|
||||
setDsId(String(def.id));
|
||||
if (def.default_index) setIndex(def.default_index);
|
||||
}
|
||||
}, [dsId]);
|
||||
|
||||
useEffect(() => { loadSources(); }, [loadSources]);
|
||||
|
||||
const syncContext = useCallback((q: string, id: string, idx: string) => {
|
||||
const ds = sources.find(s => String(s.id) === id);
|
||||
setContext({
|
||||
engine: 'elasticsearch',
|
||||
datasourceId: ds ? ds.id : undefined,
|
||||
datasourceName: ds?.name,
|
||||
query: q,
|
||||
index: idx,
|
||||
timeRange: timePreset,
|
||||
});
|
||||
}, [sources, timePreset, setContext]);
|
||||
|
||||
useEffect(() => {
|
||||
syncContext(query, dsId, index);
|
||||
}, [query, dsId, index, syncContext]);
|
||||
|
||||
const runQuery = async () => {
|
||||
if (!dsId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await logCenterApi.queryElasticsearch({
|
||||
datasource_id: Number(dsId),
|
||||
index: index.trim() || undefined,
|
||||
query: query.trim() || '*',
|
||||
size: limit,
|
||||
start: presetToStart(timePreset),
|
||||
end: new Date().toISOString(),
|
||||
});
|
||||
setEntries(res?.entries || []);
|
||||
toast({ title: '查询完成', description: `共 ${res?.total ?? 0} 条` });
|
||||
} catch (e: any) {
|
||||
toast({ title: '查询失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
setEntries([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="min-w-[200px] flex-1">
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">数据源</Label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Select value={dsId} onValueChange={setDsId}>
|
||||
<SelectTrigger className="h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
<SelectValue placeholder="选择 ES" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sources.map(s => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>{s.name}{s.is_default ? ' (默认)' : ''}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 shrink-0" onClick={() => setDsDialog(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 shrink-0" onClick={loadSources}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[140px]">
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">索引</Label>
|
||||
<Input value={index} onChange={e => setIndex(e.target.value)} className="h-9 mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
</div>
|
||||
<div className="w-[120px]">
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">时间范围</Label>
|
||||
<Select value={timePreset} onValueChange={setTimePreset}>
|
||||
<SelectTrigger className="h-9 mt-1 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_PRESETS.map(p => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button className="h-9 bg-[var(--dg-blue)] hover:opacity-90" onClick={runQuery} disabled={loading || !dsId}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Play className="h-4 w-4 mr-1" />}
|
||||
查询
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">Lucene 查询</Label>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
className="mt-1 font-mono text-xs h-10 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
|
||||
placeholder='status:500 AND path:"/api/*"'
|
||||
/>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">支持 Lucene 语法;留空则匹配全部 · 单次最多 500 条,不落库</p>
|
||||
</div>
|
||||
|
||||
<LogResultsTable entries={entries} loading={loading} />
|
||||
|
||||
<LogDatasourceDialog open={dsDialog} onOpenChange={setDsDialog} engine="elasticsearch" onSaved={loadSources} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// LogAlertFromQueryDialog — 叮当:将查询转为日志告警规则 // Logan.Li
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Loader2, BellRing } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { alertApi } from '@/api';
|
||||
import type { LogQueryContext } from './log-center-types';
|
||||
|
||||
export function LogAlertFromQueryDialog({
|
||||
open, onOpenChange, ctx, onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
ctx: LogQueryContext | null;
|
||||
onSaved?: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [expression, setExpression] = useState('');
|
||||
const [severity, setSeverity] = useState('warning');
|
||||
const [duration, setDuration] = useState(300);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !ctx) return;
|
||||
const engineLabel = ctx.engine === 'loki' ? 'Loki' : 'ES';
|
||||
setName(`${engineLabel} 日志告警`);
|
||||
setDescription(
|
||||
`数据源: ${ctx.datasourceName || '—'} · 范围: ${ctx.timeRange || '—'}${ctx.index ? ` · 索引: ${ctx.index}` : ''}`,
|
||||
);
|
||||
if (ctx.engine === 'loki') {
|
||||
setExpression(`count_over_time(${wrapLogQL(ctx.query)}[5m]) > 0`);
|
||||
} else {
|
||||
setExpression(ctx.query || '*');
|
||||
}
|
||||
}, [open, ctx]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim() || !expression.trim() || !ctx) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await alertApi.createRule({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
type: 'log',
|
||||
source: ctx.engine === 'loki' ? 'loki' : 'elasticsearch',
|
||||
expression: expression.trim(),
|
||||
severity,
|
||||
duration,
|
||||
notify_channels: [],
|
||||
});
|
||||
toast({ title: '告警规则已创建', description: '可在「日志告警」Tab 中继续配置通知渠道' });
|
||||
onOpenChange(false);
|
||||
onSaved?.();
|
||||
} catch (e: any) {
|
||||
toast({ title: '创建失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<BellRing className="h-5 w-5 text-amber-400" />
|
||||
叮当 · 添加告警
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-muted)]">
|
||||
将当前查询条件保存为日志告警规则,命中时触发通知
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{!ctx?.query?.trim() ? (
|
||||
<p className="text-sm text-amber-400/90 py-4">请先在 Loki 或 ES 查询页输入查询语句</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<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)]">描述</Label>
|
||||
<Textarea value={description} onChange={e => setDescription(e.target.value)} rows={2} className="mt-1 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] resize-none" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">触发条件</Label>
|
||||
<Textarea value={expression} onChange={e => setExpression(e.target.value)} rows={4} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">
|
||||
{ctx.engine === 'loki' ? 'LogQL 告警表达式,默认 count_over_time' : 'ES Lucene 查询,匹配到日志即告警'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">级别</Label>
|
||||
<select value={severity} onChange={e => setSeverity(e.target.value)} className="w-full h-9 mt-1 px-2 rounded-md bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-sm">
|
||||
<option value="info">提示</option>
|
||||
<option value="warning">警告</option>
|
||||
<option value="critical">严重</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">持续(秒)</Label>
|
||||
<Input type="number" value={duration} onChange={e => setDuration(Number(e.target.value) || 300)} className="mt-1 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-[var(--dg-bg-tertiary)]/60 p-2 text-[10px] font-mono text-[var(--dg-text-secondary)] break-all">
|
||||
原始查询: {ctx.query}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={saving || !ctx?.query?.trim()}>
|
||||
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
|
||||
创建规则
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** 为 count_over_time 包裹 selector // Logan.Li */
|
||||
function wrapLogQL(q: string): string {
|
||||
const t = q.trim();
|
||||
if (t.startsWith('{')) return t;
|
||||
return `{job=~".+"} |= "${t.replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// LogAlertsPanel — 日志告警规则列表 // Logan.Li
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Plus, Pencil, Trash2, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { alertApi } from '@/api';
|
||||
import { AlertRuleDialog } from '@/components/monitor/AlertRuleDialog';
|
||||
|
||||
export function LogAlertsPanel() {
|
||||
const { toast } = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [rules, setRules] = useState<any[]>([]);
|
||||
const [channels, setChannels] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [rulesRes, chRes] = await Promise.all([
|
||||
alertApi.getRules(),
|
||||
alertApi.getNotifyChannels(),
|
||||
]);
|
||||
const all = (rulesRes as any)?.list || (Array.isArray(rulesRes) ? rulesRes : []);
|
||||
setRules(all.filter((r: any) => r.type === 'log' || r.source === 'loki' || r.source === 'elasticsearch'));
|
||||
setChannels((chRes as any)?.list || []);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleDelete = async (rule: any) => {
|
||||
const ok = await confirm({ title: '删除规则', description: `删除「${rule.name}」?`, variant: 'destructive', confirmText: '删除' });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await alertApi.deleteRule(rule.id);
|
||||
toast({ title: '已删除' });
|
||||
load();
|
||||
} catch (e: any) {
|
||||
toast({ title: '删除失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const severityColor: Record<string, string> = {
|
||||
critical: 'bg-red-500/15 text-red-400',
|
||||
warning: 'bg-amber-500/15 text-amber-400',
|
||||
info: 'bg-blue-500/15 text-blue-400',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">仅保存告警策略(LogQL / Lucene),日志数据仍在外部 Loki / ES</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => { setEditing(null); setDialogOpen(true); }}>
|
||||
<Plus className="h-4 w-4 mr-1" />新建
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-[var(--dg-text-muted)]" /></div>
|
||||
) : rules.length === 0 ? (
|
||||
<Card className="dg-card">
|
||||
<CardContent className="py-12 text-center text-[var(--dg-text-muted)] text-sm">
|
||||
暂无日志告警。在 Loki/ES 查询页点击右下角「叮当」可从当前查询快速创建。
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{rules.map(rule => (
|
||||
<Card key={rule.id} className="dg-card">
|
||||
<CardContent className="py-3 px-4 flex flex-wrap items-center gap-3">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-[var(--dg-text-primary)]">{rule.name}</span>
|
||||
<Badge variant="outline" className="text-[10px] border-[var(--dg-border)]">{rule.source || 'log'}</Badge>
|
||||
<Badge className={`text-[10px] ${severityColor[rule.severity] || ''}`}>{rule.severity}</Badge>
|
||||
{!rule.enabled && rule.enabled !== undefined && (
|
||||
<Badge variant="outline" className="text-[10px] text-[var(--dg-text-muted)]">已禁用</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] font-mono text-[var(--dg-text-muted)] mt-1 truncate" title={rule.expression}>{rule.expression}</p>
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => { setEditing(rule); setDialogOpen(true); }}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-400" onClick={() => handleDelete(rule)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertRuleDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
editingRule={editing}
|
||||
notifyChannels={channels}
|
||||
onSaved={load}
|
||||
defaultType="log"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// LogCenter — 日志中心(侧边栏子菜单驱动:Loki / ES / 日志告警)// Logan.Li
|
||||
import { useState } from 'react';
|
||||
import { Navigate, useParams } from 'react-router-dom';
|
||||
import { ScrollText } from 'lucide-react';
|
||||
import { LogQueryProvider } from './LogQueryContext';
|
||||
import { LokiQueryPanel } from './LokiQueryPanel';
|
||||
import { EsQueryPanel } from './EsQueryPanel';
|
||||
import { LogAlertsPanel } from './LogAlertsPanel';
|
||||
import { LogDingAssistant } from './LogDingAssistant';
|
||||
import { LogCenterArchBanner } from './LogCenterArchBanner';
|
||||
import type { LogCenterTab } from './log-center-types';
|
||||
|
||||
const SECTION_META: Record<LogCenterTab, { title: string; description: string }> = {
|
||||
loki: { title: 'Loki 查询', description: '对接外部 Loki · LogQL 实时检索' },
|
||||
es: { title: 'ES 查询', description: '对接 Elasticsearch · 索引日志检索' },
|
||||
alerts: { title: '日志告警', description: '基于查询条件的日志告警策略' },
|
||||
};
|
||||
|
||||
export default function LogCenter() {
|
||||
const { section } = useParams<{ section: string }>();
|
||||
const tab: LogCenterTab | null =
|
||||
section && ['loki', 'es', 'alerts'].includes(section) ? (section as LogCenterTab) : null;
|
||||
const [alertReloadKey, setAlertReloadKey] = useState(0);
|
||||
|
||||
if (!tab) return <Navigate to="/log-center/loki" replace />;
|
||||
|
||||
const meta = SECTION_META[tab];
|
||||
|
||||
return (
|
||||
<LogQueryProvider>
|
||||
<div className="space-y-4 pb-20">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-xl bg-gradient-to-br from-cyan-500/20 to-blue-500/20 flex items-center justify-center border border-cyan-500/20">
|
||||
<ScrollText className="h-5 w-5 text-cyan-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-[var(--dg-text-primary)]">{meta.title}</h1>
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">{meta.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab !== 'alerts' && <LogCenterArchBanner />}
|
||||
|
||||
{tab === 'loki' && <LokiQueryPanel />}
|
||||
{tab === 'es' && <EsQueryPanel />}
|
||||
{tab === 'alerts' && <LogAlertsPanel key={alertReloadKey} />}
|
||||
|
||||
{tab !== 'alerts' && (
|
||||
<LogDingAssistant onRuleCreated={() => setAlertReloadKey(k => k + 1)} />
|
||||
)}
|
||||
</div>
|
||||
</LogQueryProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// LogCenterArchBanner — 架构说明:平台不落库海量日志 // Logan.Li
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
export function LogCenterArchBanner() {
|
||||
return (
|
||||
<div className="flex gap-3 rounded-lg border border-cyan-500/20 bg-cyan-500/5 px-4 py-3 text-xs text-[var(--dg-text-secondary)] leading-relaxed">
|
||||
<Info className="h-4 w-4 shrink-0 text-cyan-400 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-[var(--dg-text-primary)] font-medium mb-1">无状态查询网关</p>
|
||||
<p>
|
||||
AIOps 不存储 Loki / Elasticsearch 中的海量日志,仅保存<strong className="text-[var(--dg-text-primary)] font-normal">数据源连接</strong>与
|
||||
<strong className="text-[var(--dg-text-primary)] font-normal">告警策略</strong>。查询时实时转发至外部集群,结果仅在当前页面展示,刷新即清空。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// LogDatasourceDialog — 添加/编辑 Loki / ES 数据源 // Logan.Li
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Loader2, Plug } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { logCenterApi, type LogDataSource } from '@/api/monitor/log-center';
|
||||
import type { LogEngine } from './log-center-types';
|
||||
|
||||
export function LogDatasourceDialog({
|
||||
open, onOpenChange, engine, editing, onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
engine: LogEngine;
|
||||
editing?: LogDataSource | null;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [name, setName] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [defaultIndex, setDefaultIndex] = useState('logstash-*');
|
||||
const [basicAuth, setBasicAuth] = useState(false);
|
||||
const [user, setUser] = useState('');
|
||||
const [pass, setPass] = useState('');
|
||||
const [skipTLS, setSkipTLS] = useState(false);
|
||||
const [isDefault, setIsDefault] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (editing) {
|
||||
setName(editing.name);
|
||||
setUrl(editing.url);
|
||||
setDefaultIndex(editing.default_index || 'logstash-*');
|
||||
setBasicAuth(!!editing.basic_auth);
|
||||
setUser(editing.basic_auth_user || '');
|
||||
setPass('');
|
||||
setSkipTLS(!!editing.skip_tls_verify);
|
||||
setIsDefault(!!editing.is_default);
|
||||
} else {
|
||||
setName(engine === 'loki' ? 'Loki' : 'Elasticsearch');
|
||||
setUrl(engine === 'loki' ? 'http://localhost:3100' : 'http://localhost:9200');
|
||||
setDefaultIndex('logstash-*');
|
||||
setBasicAuth(false);
|
||||
setUser('');
|
||||
setPass('');
|
||||
setSkipTLS(false);
|
||||
setIsDefault(false);
|
||||
}
|
||||
}, [open, editing, engine]);
|
||||
|
||||
const payload = () => ({
|
||||
name: name.trim(),
|
||||
type: engine,
|
||||
url: url.trim(),
|
||||
basic_auth: basicAuth,
|
||||
basic_auth_user: user,
|
||||
basic_auth_pass: pass,
|
||||
skip_tls_verify: skipTLS,
|
||||
default_index: engine === 'elasticsearch' ? defaultIndex : undefined,
|
||||
is_default: isDefault,
|
||||
});
|
||||
|
||||
const handleTest = async () => {
|
||||
setTesting(true);
|
||||
try {
|
||||
await logCenterApi.testDatasource(payload());
|
||||
toast({ title: '连接成功' });
|
||||
} catch (e: any) {
|
||||
toast({ title: '连接失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim() || !url.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
await logCenterApi.updateDatasource(editing.id, payload());
|
||||
} else {
|
||||
await logCenterApi.createDatasource(payload());
|
||||
}
|
||||
toast({ title: '已保存' });
|
||||
onOpenChange(false);
|
||||
onSaved();
|
||||
} catch (e: any) {
|
||||
toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<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 ? '编辑' : '添加'}{engine === 'loki' ? ' Loki' : ' Elasticsearch'} 数据源
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-muted)]">
|
||||
仅保存连接信息,日志本体存储在您的 Loki / ES 集群
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-1">
|
||||
<div>
|
||||
<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)]">地址</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>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">默认索引</Label>
|
||||
<Input value={defaultIndex} onChange={e => setDefaultIndex(e.target.value)} className="mt-1 h-9 font-mono text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">基础认证</Label>
|
||||
<Switch checked={basicAuth} onCheckedChange={setBasicAuth} />
|
||||
</div>
|
||||
{basicAuth && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<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>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<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)]">设为默认</Label>
|
||||
<Switch checked={isDefault} onCheckedChange={setIsDefault} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleTest} disabled={testing}>
|
||||
{testing ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Plug className="h-4 w-4 mr-1" />}
|
||||
测试
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={saving}>
|
||||
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// LogDingAssistant — 小叮当浮动助手:查询转告警 // Logan.Li
|
||||
import { useState } from 'react';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLogQueryContext } from './LogQueryContext';
|
||||
import { LogAlertFromQueryDialog } from './LogAlertFromQueryDialog';
|
||||
|
||||
export function LogDingAssistant({ onRuleCreated }: { onRuleCreated?: () => void }) {
|
||||
const { context } = useLogQueryContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed bottom-6 right-6 z-40 flex flex-col items-end gap-2">
|
||||
<div className="hidden sm:block rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] px-3 py-1.5 text-[11px] text-[var(--dg-text-muted)] shadow-lg opacity-0 hover:opacity-100 transition-opacity pointer-events-none">
|
||||
将当前查询添加为告警
|
||||
</div>
|
||||
<Button
|
||||
size="lg"
|
||||
className="h-12 rounded-full shadow-lg bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-400 hover:to-orange-400 text-white gap-2 px-5"
|
||||
onClick={() => setOpen(true)}
|
||||
title="叮当 · 添加告警"
|
||||
>
|
||||
<Sparkles className="h-5 w-5" />
|
||||
<span className="font-medium">叮当</span>
|
||||
</Button>
|
||||
</div>
|
||||
<LogAlertFromQueryDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
ctx={context}
|
||||
onSaved={onRuleCreated}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// LogQueryContext — 查询上下文(供叮当助手读取)// Logan.Li
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||
import type { LogQueryContext as LogQueryCtx } from './log-center-types';
|
||||
|
||||
interface LogQueryContextValue {
|
||||
context: LogQueryCtx | null;
|
||||
setContext: (ctx: LogQueryCtx | null) => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<LogQueryContextValue | null>(null);
|
||||
|
||||
export function LogQueryProvider({ children }: { children: ReactNode }) {
|
||||
const [context, setContext] = useState<LogQueryCtx | null>(null);
|
||||
const stableSet = useCallback((ctx: LogQueryCtx | null) => setContext(ctx), []);
|
||||
return (
|
||||
<Ctx.Provider value={{ context, setContext: stableSet }}>
|
||||
{children}
|
||||
</Ctx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLogQueryContext() {
|
||||
const v = useContext(Ctx);
|
||||
if (!v) throw new Error('useLogQueryContext must be used within LogQueryProvider');
|
||||
return v;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// LogResultsTable — 日志查询结果表格 // Logan.Li
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { LogQueryEntry } from '@/api/monitor/log-center';
|
||||
|
||||
const levelColor: Record<string, string> = {
|
||||
error: 'bg-red-500/15 text-red-400 border-red-500/30',
|
||||
warn: 'bg-amber-500/15 text-amber-400 border-amber-500/30',
|
||||
warning: 'bg-amber-500/15 text-amber-400 border-amber-500/30',
|
||||
info: 'bg-blue-500/15 text-blue-400 border-blue-500/30',
|
||||
debug: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
|
||||
};
|
||||
|
||||
export function LogResultsTable({ entries, loading }: { entries: LogQueryEntry[]; loading?: boolean }) {
|
||||
if (loading) {
|
||||
return <div className="text-center py-16 text-[var(--dg-text-muted)] text-sm">查询中...</div>;
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
return <div className="text-center py-16 text-[var(--dg-text-muted)] text-sm">暂无日志,调整查询条件后重试</div>;
|
||||
}
|
||||
return (
|
||||
<div className="border border-[var(--dg-border)] rounded-lg overflow-hidden">
|
||||
<div className="grid grid-cols-[160px_72px_1fr] gap-2 px-3 py-2 bg-[var(--dg-bg-tertiary)]/50 text-[11px] font-medium text-[var(--dg-text-muted)] uppercase">
|
||||
<span>时间</span>
|
||||
<span>级别</span>
|
||||
<span>内容</span>
|
||||
</div>
|
||||
<div className="max-h-[480px] overflow-y-auto divide-y divide-[var(--dg-border)]">
|
||||
{entries.map((e, i) => (
|
||||
<div key={i} className="grid grid-cols-[160px_72px_1fr] gap-2 px-3 py-2 text-xs hover:bg-[var(--dg-bg-tertiary)]/30">
|
||||
<span className="font-mono text-[var(--dg-text-muted)] truncate" title={e.timestamp}>{e.timestamp}</span>
|
||||
<span>
|
||||
{e.level ? (
|
||||
<Badge variant="outline" className={`text-[10px] ${levelColor[e.level.toLowerCase()] || 'border-[var(--dg-border)]'}`}>{e.level}</Badge>
|
||||
) : '—'}
|
||||
</span>
|
||||
<pre className="font-mono text-[var(--dg-text-secondary)] whitespace-pre-wrap break-all leading-relaxed">{e.message}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// LokiQueryPanel — LogQL 查询 // Logan.Li
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Play, Plus, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { logCenterApi, type LogDataSource, type LogQueryEntry } from '@/api/monitor/log-center';
|
||||
import { LogDatasourceDialog } from './LogDatasourceDialog';
|
||||
import { LogResultsTable } from './LogResultsTable';
|
||||
import { useLogQueryContext } from './LogQueryContext';
|
||||
|
||||
const TIME_PRESETS = [
|
||||
{ label: '15 分钟', value: '15m' },
|
||||
{ label: '1 小时', value: '1h' },
|
||||
{ label: '6 小时', value: '6h' },
|
||||
{ label: '24 小时', value: '24h' },
|
||||
];
|
||||
|
||||
function presetToStart(preset: string): string {
|
||||
const m = preset.match(/^(\d+)(m|h)$/);
|
||||
if (!m) return new Date(Date.now() - 3600000).toISOString();
|
||||
const n = parseInt(m[1], 10);
|
||||
const ms = m[2] === 'h' ? n * 3600000 : n * 60000;
|
||||
return new Date(Date.now() - ms).toISOString();
|
||||
}
|
||||
|
||||
export function LokiQueryPanel() {
|
||||
const { toast } = useToast();
|
||||
const { setContext } = useLogQueryContext();
|
||||
const [sources, setSources] = useState<LogDataSource[]>([]);
|
||||
const [dsId, setDsId] = useState<string>('');
|
||||
const [query, setQuery] = useState('{namespace="default"} |= "error"');
|
||||
const [timePreset, setTimePreset] = useState('1h');
|
||||
const [limit, setLimit] = useState(200);
|
||||
const [entries, setEntries] = useState<LogQueryEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dsDialog, setDsDialog] = useState(false);
|
||||
|
||||
const loadSources = useCallback(async () => {
|
||||
const list = await logCenterApi.listDatasources('loki').catch(() => []);
|
||||
const arr = Array.isArray(list) ? list : [];
|
||||
setSources(arr);
|
||||
if (!dsId && arr.length) {
|
||||
const def = arr.find(s => s.is_default) || arr[0];
|
||||
setDsId(String(def.id));
|
||||
}
|
||||
}, [dsId]);
|
||||
|
||||
useEffect(() => { loadSources(); }, [loadSources]);
|
||||
|
||||
const syncContext = useCallback((q: string, id: string) => {
|
||||
const ds = sources.find(s => String(s.id) === id);
|
||||
setContext({
|
||||
engine: 'loki',
|
||||
datasourceId: ds ? ds.id : undefined,
|
||||
datasourceName: ds?.name,
|
||||
query: q,
|
||||
timeRange: timePreset,
|
||||
});
|
||||
}, [sources, timePreset, setContext]);
|
||||
|
||||
useEffect(() => {
|
||||
syncContext(query, dsId);
|
||||
}, [query, dsId, syncContext]);
|
||||
|
||||
const runQuery = async () => {
|
||||
if (!dsId || !query.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await logCenterApi.queryLoki({
|
||||
datasource_id: Number(dsId),
|
||||
query: query.trim(),
|
||||
limit,
|
||||
start: presetToStart(timePreset),
|
||||
end: new Date().toISOString(),
|
||||
});
|
||||
setEntries(res?.entries || []);
|
||||
toast({ title: '查询完成', description: `共 ${res?.total ?? 0} 条` });
|
||||
} catch (e: any) {
|
||||
toast({ title: '查询失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
setEntries([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="min-w-[200px] flex-1">
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">数据源</Label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Select value={dsId} onValueChange={setDsId}>
|
||||
<SelectTrigger className="h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
<SelectValue placeholder="选择 Loki" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sources.map(s => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>{s.name}{s.is_default ? ' (默认)' : ''}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 shrink-0" onClick={() => setDsDialog(true)} title="添加数据源">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 shrink-0" onClick={loadSources} title="刷新">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[120px]">
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">时间范围</Label>
|
||||
<Select value={timePreset} onValueChange={setTimePreset}>
|
||||
<SelectTrigger className="h-9 mt-1 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_PRESETS.map(p => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-[88px]">
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">条数</Label>
|
||||
<Input type="number" value={limit} onChange={e => setLimit(Number(e.target.value) || 200)} className="h-9 mt-1 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
</div>
|
||||
<Button className="h-9 bg-[var(--dg-blue)] hover:opacity-90" onClick={runQuery} disabled={loading || !dsId}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Play className="h-4 w-4 mr-1" />}
|
||||
查询
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)]">LogQL</Label>
|
||||
<Textarea
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
rows={3}
|
||||
className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] resize-y"
|
||||
placeholder='{app="nginx"} |= "error"'
|
||||
/>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">
|
||||
示例:{'{job="varlogs"}'} | json | level="error" · 单次最多 500 条,不落库
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LogResultsTable entries={entries} loading={loading} />
|
||||
|
||||
<LogDatasourceDialog open={dsDialog} onOpenChange={setDsDialog} engine="loki" onSaved={loadSources} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// log-center-types.ts — 日志中心类型(无状态:仅存连接与告警策略)// Logan.Li
|
||||
|
||||
export type LogCenterTab = 'loki' | 'es' | 'alerts';
|
||||
|
||||
export type LogEngine = 'loki' | 'elasticsearch';
|
||||
|
||||
/** 叮当助手可读取的当前查询上下文 // Logan.Li */
|
||||
export interface LogQueryContext {
|
||||
engine: LogEngine;
|
||||
datasourceId?: number;
|
||||
datasourceName?: string;
|
||||
query: string;
|
||||
index?: string;
|
||||
timeRange?: string;
|
||||
}
|
||||
Reference in new issue
Block a user