fix(log-center): ES 时间戳双字段兼容 + AI助手按钮去重清理

This commit is contained in:
attacker committed 2026-06-27 16:31:06 +08:00
1 parent 36bd0a6155
commit 084a3be2cf
4 files changed
+72 -205

No files matched your search

+27 -8
View File
@@ -22,7 +22,7 @@ type LogProxyHandler struct {
ds *LogDSHandler
}
const maxLogQueryLimit = 500 // 单次查询上限,防止平台承载过大数据 // Logan.Li
const maxLogQueryLimit = 500
func NewLogProxyHandler(ds *LogDSHandler) *LogProxyHandler {
return &LogProxyHandler{ds: ds}
@@ -149,19 +149,36 @@ func (h *LogProxyHandler) QueryES(c *gin.Context) {
},
}
if req.Start != "" || req.End != "" {
rangeQ := map[string]interface{}{"range": map[string]interface{}{"@timestamp": map[string]string{}}}
r := rangeQ["range"].(map[string]interface{})["@timestamp"].(map[string]string)
if req.Start != "" {
r["gte"] = req.Start
// ES 7.2 date_time 格式要求 +00:00 而非 Z;兼容 @timestamp 与 timestamp // Logan.Li
buildRangeVal := func(v string) string {
return strings.Replace(v, "Z", "+00:00", 1)
}
if req.End != "" {
r["lte"] = req.End
rangeCond := map[string]interface{}{
"bool": map[string]interface{}{
"should": []interface{}{
map[string]interface{}{"range": map[string]interface{}{"@timestamp": map[string]string{}}},
map[string]interface{}{"range": map[string]interface{}{"timestamp": map[string]string{}}},
},
"minimum_should_match": 1,
},
}
for _, clause := range rangeCond["bool"].(map[string]interface{})["should"].([]interface{}) {
rng := clause.(map[string]interface{})["range"].(map[string]interface{})
for _, v := range rng {
r := v.(map[string]string)
if req.Start != "" {
r["gte"] = buildRangeVal(req.Start)
}
if req.End != "" {
r["lte"] = buildRangeVal(req.End)
}
}
}
searchBody["query"] = map[string]interface{}{
"bool": map[string]interface{}{
"must": []interface{}{
searchBody["query"],
rangeQ,
rangeCond,
},
},
}
@@ -200,6 +217,8 @@ func parseLogTime(s string) (time.Time, error) {
}
return time.Unix(n, 0), nil
}
// ES 7.2 date_time 不接受 Z,转 +00:00
s = strings.Replace(s, "Z", "+00:00", 1)
return time.Parse(time.RFC3339, s)
}
+11 -193
View File
@@ -6,23 +6,6 @@ import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { toast } from 'sonner';
import { aiApi, alertApi, agentApi, dashboardStatsApi, type LLMConfigData, type ChatHistoryItem } from '@/api';
import {
@@ -40,7 +23,6 @@ import {
Brain,
Loader2,
CheckCircle2,
Wifi,
Square,
} from 'lucide-react';
import type { JsonData } from '@/types/common';
@@ -154,9 +136,6 @@ export function AIOpsAssistant() {
const abortRef = useRef<AbortController | null>(null);
// 组件卸载时中止进行中的流式请求,避免 setState on unmounted // Logan.Li
useEffect(() => () => abortRef.current?.abort(), []);
const [isConfigOpen, setIsConfigOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [config, setConfig] = useState<LLMConfigData>(defaultConfig);
const [configReady, setConfigReady] = useState(false); // 已配置且启用
const [contextLoading, setContextLoading] = useState<string | null>(null); // 快捷操作拉取平台数据中
@@ -209,50 +188,6 @@ export function AIOpsAssistant() {
loadHistory();
}, []);
// 切换提供商时自动填充默认 endpoint + 模型 // Logan.Li
const handleProviderChange = (provider: string) => {
const p = LLM_PROVIDERS.find((x) => x.id === provider);
setConfig((prev) => ({
...prev,
provider,
endpoint: p?.defaultEndpoint || prev.endpoint,
model: p?.models[0] || prev.model,
}));
};
// 保存配置 // Logan.Li
const handleSaveConfig = async () => {
if (config.enabled && !config.api_key && config.provider !== 'ollama') {
toast.error('请填写 API Key');
return;
}
setSaving(true);
try {
const saved = await aiApi.saveConfig(config);
setConfig(saved);
setConfigReady(!!(saved.enabled && (saved as JsonData).configured));
toast.success('配置已保存');
setIsConfigOpen(false);
} catch (e) {
toast.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
};
// 测试连接 // Logan.Li
const handleTest = async () => {
setTesting(true);
try {
await aiApi.testConnection();
toast.success('LLM 服务连接成功');
} catch (e) {
toast.error(e instanceof Error ? e.message : '连接失败');
} finally {
setTesting(false);
}
};
// 发送一条消息的核心逻辑(流式输出,逐步填充助手气泡) // Logan.Li
const sendMessage = async (text: string) => {
const userMsg: Message = {
@@ -367,7 +302,6 @@ export function AIOpsAssistant() {
toast.success('已开始新对话');
};
const currentProvider = LLM_PROVIDERS.find((p) => p.id === config.provider);
return (
<div className="space-y-6">
@@ -383,12 +317,21 @@ export function AIOpsAssistant() {
</div>
</div>
<div className="flex items-center gap-3">
{configReady && (
{configReady ? (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
<CheckCircle2 className="h-3 w-3 mr-1" />
{config.model}
</Badge>
)}
) : null}
<Button
variant="outline"
size="sm"
className="border-slate-700 text-slate-300 hover:text-purple-400"
onClick={() => navigate('/ai-llm-config')}
>
<Settings className="h-4 w-4 mr-1" />
模型配置
</Button>
</div>
</div>
@@ -564,131 +507,6 @@ export function AIOpsAssistant() {
</Card>
</div>
{/* LLM Config Dialog */}
<Dialog open={isConfigOpen} onOpenChange={setIsConfigOpen}>
<DialogContent className="max-w-2xl bg-slate-900 border-slate-700 text-slate-100">
<DialogHeader>
<DialogTitle>大模型配置</DialogTitle>
<DialogDescription className="text-slate-500">
配置 AI 助手使用的大语言模型(支持 OpenAI 兼容接口)
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4 max-h-[60vh] overflow-y-auto pr-1">
{/* 启用开关 */}
<div className="flex items-center justify-between rounded-lg border border-slate-700 p-3">
<div>
<p className="text-sm font-medium text-slate-200">启用 AI 助手</p>
<p className="text-xs text-slate-500">关闭后将无法进行对话</p>
</div>
<Switch checked={config.enabled} onCheckedChange={(v) => setConfig({ ...config, enabled: v })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-slate-300">提供商</Label>
<Select value={config.provider} onValueChange={handleProviderChange}>
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-100 mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
{LLM_PROVIDERS.map((p) => (
<SelectItem key={p.id} value={p.id} className="text-slate-100">{p.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-slate-300">模型</Label>
{currentProvider && currentProvider.models.length > 0 ? (
<Select value={config.model} onValueChange={(v) => setConfig({ ...config, model: v })}>
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-100 mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
{currentProvider.models.map((m) => (
<SelectItem key={m} value={m} className="text-slate-100">{m}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={config.model}
onChange={(e) => setConfig({ ...config, model: e.target.value })}
placeholder="输入模型名称"
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
)}
</div>
</div>
<div>
<Label className="text-slate-300">API Key {config.provider === 'ollama' && <span className="text-xs text-slate-500">(本地 Ollama 通常无需)</span>}</Label>
<Input
type="password"
value={config.api_key}
onChange={(e) => setConfig({ ...config, api_key: e.target.value })}
placeholder="sk-..."
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
</div>
<div>
<Label className="text-slate-300">API Endpoint</Label>
<Input
value={config.endpoint}
onChange={(e) => setConfig({ ...config, endpoint: e.target.value })}
placeholder="https://api.openai.com/v1"
className="bg-slate-800 border-slate-700 text-slate-100 mt-1 font-mono text-sm"
/>
<p className="text-xs text-slate-500 mt-1">基础地址(不含 /chat/completions),OpenAI 兼容格式</p>
</div>
<div>
<Label className="text-slate-300">系统提示词(可选)</Label>
<textarea
value={config.system_prompt}
onChange={(e) => setConfig({ ...config, system_prompt: e.target.value })}
placeholder="留空则使用内置 AIOps 运维助手角色提示词"
className="w-full bg-slate-800 border border-slate-700 text-slate-100 mt-1 rounded-md p-2 text-sm min-h-[70px] resize-y"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-slate-300">Temperature</Label>
<Input
type="number"
min={0}
max={2}
step={0.1}
value={Math.round(config.temperature * 10) / 10}
onChange={(e) => setConfig({ ...config, temperature: parseFloat(e.target.value) || 0 })}
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
<p className="text-xs text-slate-500 mt-1">值越高回答越创造性</p>
</div>
<div>
<Label className="text-slate-300">Max Tokens</Label>
<Input
type="number"
value={config.max_tokens}
onChange={(e) => setConfig({ ...config, max_tokens: parseInt(e.target.value) || 2000 })}
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
</div>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={handleTest} disabled={testing} className="border-slate-700 text-slate-300">
{testing ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Wifi className="h-4 w-4 mr-2" />}
测试连接
</Button>
<Button variant="outline" onClick={() => setIsConfigOpen(false)} className="border-slate-700 text-slate-300">
取消
</Button>
<Button className="bg-purple-600 hover:bg-purple-700" onClick={handleSaveConfig} disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
保存配置
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -117,6 +117,16 @@ export function renderStepProgress(instance: JsonData) {
{isRejected && <XCircle className="h-3 w-3" />}
{isCurrent && !isRejected && <Loader2 className="h-3 w-3 animate-spin" />}
<span>{step.name || step.approver_name || `步骤 ${index + 1}`}</span>
{step.step_type && step.step_type !== 'submit' && (
<span className={cn(
'text-[10px] px-1 py-0 rounded',
step.step_type === 'execute'
? 'bg-green-500/15 text-green-400'
: 'bg-purple-500/15 text-purple-400'
)}>
{step.step_type === 'execute' ? '执行' : '审核'}
</span>
)}
</div>
{index < steps.length - 1 && (
<div className={cn('w-4 h-px', isCompleted ? 'bg-emerald-500/30' : 'bg-[var(--dg-border)]')} />
@@ -166,6 +166,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
const [historyPanelOpen, setHistoryPanelOpen] = useState(false);
const [alertDialogOpen, setAlertDialogOpen] = useState(false);
const [saveMenuOpen, setSaveMenuOpen] = useState(false);
const [hasQueried, setHasQueried] = useState(false);
// 数据源切换后更新 dsId // Logan.Li
useEffect(() => {
@@ -194,7 +195,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
const mock = generateMockEntries(effEngine as LogEngine, 120);
setEntries(mock); setHistogramData(generateHistogramData(mock)); setTotalCount(mock.length * 14);
setQueryTime(Math.round(performance.now() - t0)); setStatTable(generateMockStatTable(effEngine as LogEngine));
setClusters(generateMockClusters()); setPage(1); setLoading(false);
setClusters(generateMockClusters()); setPage(1); setLoading(false); setHasQueried(true);
historyStorage.add({ engine: effEngine as LogEngine, datasourceName: currentSource.name, query, timeRange: timeRange.label, resultCount: mock.length });
}, 300);
return;
@@ -243,10 +244,11 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
setStatTable(generateMockStatTable(effEngine as LogEngine));
setClusters(generateMockClusters());
setPage(1);
setHasQueried(true);
historyStorage.add({ engine: effEngine as LogEngine, datasourceName: currentSource.name, query, timeRange: timeRange.label, resultCount: mapped.length });
} catch (e: any) {
toast({ title: '查询失败', description: e?.message || String(e), variant: 'destructive' });
setEntries([]); setTotalCount(0);
setEntries([]); setTotalCount(0); setHasQueried(true);
} finally {
setLoading(false);
}
@@ -373,13 +375,31 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
</div>
)}
{/* 空状态 */}
{!loading && entries.length === 0 && (
{/* 空状态:从未查询 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)' }}>输入查询语句并点击「查询」查看结果</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>
)}
{!loading && entries.length === 0 && hasQueried && (
<div className="border rounded-lg flex flex-col items-center justify-center gap-3" style={{ borderColor: 'var(--dg-border)', minHeight: '300px' }}>
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ background: 'var(--dg-bg-tertiary)' }}>
<svg className="h-6 w-6" style={{ color: 'var(--dg-text-muted)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
</div>
<div className="text-center">
<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)' }}>
查询已执行({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" />重新查询
</button>
</div>
)}
{/* Loading */}
{loading && entries.length === 0 && (