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:
Logan committed 2026-06-26 23:18:05 +08:00
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 &gt; 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;
}