feat(aiops): 新增「智能运维」——AI 日志分析(P0,不扫日志内容)
CI / Backend (go vet + test) (push) Waiting to run
CI / Frontend (lint + build + test) (push) Waiting to run

=== 为什么这样设计(成本优先)===
全集群 370GB/天,把日志原文丢给模型是不可能的(一次几百万 token)。
所以整条链路只做一件事:**把 GB 级日志压成 KB 级信号**。

P0 的信号来源全部是索引/快照,**零日志内容扫描**:
  体积 + 趋势   → index/volume_range(实测 24h 仅 54ms)
  条数/流数     → index/stats,且只在 ≤2 小时窗口用
                  (全天 index/stats 实测 20s,绝不能进 AI 路径)
  应用排行      → 平台小时级预聚合快照(毫秒)
  基线对比      → 上一个等长窗口的 volume_range
  告警          → 平台投递记录
实测一次分析:3~6 秒、输入 600~1400 tokens(约 ¥0.005/次)。

=== 后端 ===
models/ai.go            AIConfig(APIKey 加密存储)+ AIAnalysisRun(审计)
services/ai_config.go   读写配置、掩码返回、每日 token 用量统计
services/ai_provider.go OpenAI 兼容客户端 + 连通性测试
                        (DeepSeek/通义/内网 vLLM/Ollama 通用)
services/ai_redact.go   脱敏:凭据/邮箱/手机号/身份证 → 占位符,
                        **保留内网 IP 与端口**(排查必需)
services/ai_signal.go   信号采集 + 渲染成紧凑文本(省 token)
services/ai_analyze.go  结构化输出解析、配额闸门、审计落库
controllers/ai.go       /ai/config /ai/config/test /ai/analyze /ai/runs

成本闸门(三层):
  1) 单次:时间范围 ≤24h、maxTokens ≤1500
  2) 每用户每日 token 上限(默认 50 万,可配,超限直接拒绝并说明)
  3) 审计只留最近 500 条,避免表膨胀

=== 前端 ===
新页面 /aiops(侧栏「智能运维」,管理员),页内单层 tab:
  日志分析 / 模型配置 / 分析历史
- 分析:数据源 + 时间预设(15m/1h/6h/24h) + 可选提问 → 结论/证据/建议/不确定 四区
       顶部显示本次消耗(in/out tokens、耗时),可查看「发给模型的完整内容」
- 配置:启用开关、Base URL、模型、API Key(掩码)、温度、输出上限、超时、
       每日配额、额外脱敏正则;按钮顺序改为「测试连接 → 保存配置」
- 历史:服务端分页(20/页),点行看详情与 payload

=== 两个真实踩坑(已修)===
1) 密码含 @ 导致脱敏漏掉(mysql://user:P@ssw0rd@host)
   → URL basic-auth 规则改为贪婪匹配到最后一个 @,由单测锁定
2) 结论里的时间是 UTC(容器无 tzdata,time.Local=UTC),
   模型写"凌晨 6 点下跌"而运维当时是下午 2 点
   → 显式用东八区展示(LOKISTACK_DISPLAY_TZ=UTC 可覆盖)

=== 实测(69147008fcf4)===
  配置保存 → key 掩码 sk-***e286;连通性测试返回「正常」
  分析 15 分钟窗口:4.1s,结论识别出"窗口末段体积断崖约 -60%"
  信号时间 14:23~14:38(本地时间正确)
  历史分页 total=3,tokens/耗时字段完整
  单测:脱敏 2 个用例 + 模型输出解析 4 个用例 + 6 条 AI 路由断言
This commit is contained in:
opsx committed 2026-10-31 06:39:01 +00:00
1 parent 3861b8b128
commit 7be41f0a6b
19 files changed
+2236 -1

No files matched your search

+94
View File
@@ -0,0 +1,94 @@
import { http } from '@/api/http'
export interface AIConfig {
enabled: boolean
provider: string
baseUrl: string
/** 后端永远返回掩码(sk-***abcd),保存时留空表示不修改 */
apiKey: string
model: string
temperature: number
maxTokens: number
timeoutSec: number
dailyTokenCap: number
maxScanBytes: number
redactExtra: string[]
}
export interface AIConfigResponse {
config: AIConfig
tokenUsedToday: number
}
export interface AIFinding {
title: string
evidence: string
severity: 'high' | 'medium' | 'low' | string
}
export interface AIAnalysisResult {
summary: string
findings: AIFinding[]
actions: string[]
unknowns: string[]
parseError?: string
}
export interface AISignalBundle {
sourceId: number
sourceName: string
selector: string
from: string
to: string
rangeMinutes: number
totalBytes: number
totalEntries: number
entriesExact: boolean
apps: Array<{ app: string; namespace: string; entries: number; bytes: number; share: string }>
alerts: Array<{ ruleName: string; status: string; count: number; createdAt: string }>
baseline?: { previousBytes: number; currentBytes: number; deltaPercent: number; comparisonNote: string }
snapshotHours: number
warnings: string[]
}
export interface AIRun {
id: number
userId: number
username: string
sourceId: number
sourceName: string
rangeMinutes: number
model: string
question: string
status: string
error: string
conclusion: string
promptPayload: string
promptTokens: number
completionTokens: number
scanBytes: number
durationMs: number
createdAt: string
}
export interface AIAnalyzeResponse {
result: AIAnalysisResult
signals: AISignalBundle
prompt: string
run: AIRun
warnings: string[]
}
export const aiApi = {
getConfig: () => http.get<AIConfigResponse>('/ai/config'),
saveConfig: (data: Partial<AIConfig>) => http.put<{ config: AIConfig }>('/ai/config', data),
testConfig: (data: { baseUrl?: string; model?: string; apiKey?: string }) =>
http.post<{ reply: string; model: string }>('/ai/config/test', data, { timeout: 45000 }),
analyze: (data: { sourceId: number; start: number; end: number; question?: string }) =>
http.post<AIAnalyzeResponse>('/ai/analyze', data, { timeout: 180000 }),
runs: (page: number, pageSize: number) =>
http.get<{ list: AIRun[]; total: number; page: number; pageSize: number }>(
`/ai/runs?page=${page}&pageSize=${pageSize}`,
),
run: (id: number) => http.get<AIRun>(`/ai/runs/${id}`),
}
+4
View File
@@ -28,6 +28,8 @@ import zhAlerts from './locales/zh/alerts'
import enAlerts from './locales/en/alerts'
import zhSettings from './locales/zh/settings'
import enSettings from './locales/en/settings'
import zhAiops from './locales/zh/aiops'
import enAiops from './locales/en/aiops'
const LANG_KEY = 'lokistack.i18n.lang'
export type AppLanguage = 'zh' | 'en'
@@ -62,6 +64,7 @@ void i18n.use(initReactI18next).init({
users: zhUsers,
alerts: zhAlerts,
settings: zhSettings,
aiops: zhAiops,
},
en: {
common: enCommon,
@@ -76,6 +79,7 @@ void i18n.use(initReactI18next).init({
users: enUsers,
alerts: enAlerts,
settings: enSettings,
aiops: enAiops,
},
},
lng: detectLanguage(),
+98
View File
@@ -0,0 +1,98 @@
export default {
title: 'AIOps',
subtitle: 'Let a model read log signals: statistics first, conclusions second — raw logs never leave the platform',
tabs: {
analyze: 'Analyze',
config: 'Model',
history: 'History',
},
analyze: {
source: 'Data source',
range: 'Time range',
question: 'Extra question (optional)',
questionPlaceholder: 'e.g. is this volume spike related to a rollout?',
run: 'Analyze',
running: 'Analyzing…',
runHint: 'Reads index and pre-aggregated stats only (no log content) — usually 3–15s',
empty: 'Pick a data source and time range, then click Analyze',
notEnabled: 'AI is disabled — configure and enable it on the Model tab first',
lastRun: 'Cost of this run',
promptTokens: 'input tokens',
completionTokens: 'output tokens',
duration: 'duration',
viewPrompt: 'View what was sent',
promptTitle: 'Exact payload sent to the model (redacted)',
promptHint: 'Passwords, tokens, emails and phone numbers are replaced with placeholders; private IPs and ports are kept for troubleshooting.',
},
result: {
summary: 'Assessment',
findings: 'Findings & evidence',
actions: 'Suggested actions',
unknowns: 'What cannot be determined',
severityHigh: 'high',
severityMedium: 'medium',
severityLow: 'low',
noFindings: 'No obvious issues found',
noActions: 'Nothing to suggest',
noUnknowns: 'None',
},
signals: {
title: 'Signals used',
volume: 'Volume',
entries: 'Entries',
apps: 'Top apps',
baseline: 'Baseline',
alerts: 'Alerts in window',
trend: 'Volume trend',
exact: 'exact',
estimated: 'estimated',
none: 'none',
},
config: {
enabled: 'Enable AI analysis',
enabledHint: 'When off, Analyze returns a clear "not enabled" message',
provider: 'Protocol',
providerHint: 'OpenAI-compatible only: DeepSeek / Qwen / self-hosted vLLM / Ollama(/v1)',
baseUrl: 'Base URL',
baseUrlHint: 'e.g. https://api.deepseek.com/v1 or http://172.19.16.10:8000/v1',
apiKey: 'API Key',
apiKeyHint: 'Stored encrypted and shown masked. Leave empty to keep the current key.',
model: 'Model',
modelHint: 'e.g. deepseek-chat / qwen2.5:14b',
temperature: 'Temperature',
temperatureHint: '0–0.2 recommended for stable analysis',
maxTokens: 'Max output tokens',
timeoutSec: 'Timeout (s)',
dailyTokenCap: 'Daily token cap per user',
dailyTokenCapHint: '0 = unlimited. This is the cost gate; exceeding it blocks analysis for the day',
redactExtra: 'Extra redaction rules',
redactExtraHint: 'One regex per line; matches become <REDACTED>. Built-in rules already cover passwords/tokens/emails/phones',
save: 'Save',
test: 'Test connection',
testing: 'Testing…',
testOk: 'Connection OK, model replied: {{reply}}',
usedToday: '{{used}} tokens used today',
},
history: {
time: 'Time',
user: 'User',
source: 'Source',
status: 'Status',
tokens: 'Tokens',
duration: 'Duration',
range: 'Range',
empty: 'No analyses yet',
detail: 'Analysis detail',
statusOk: 'ok',
statusFailed: 'failed',
statusQuota: 'quota exceeded',
},
commonEmpty: 'No data',
}
+2 -1
View File
@@ -16,7 +16,8 @@ export default {
collectors: 'Log Collectors',
logs: 'Log Query',
alerts: 'Alerts',
analysis: 'Log Analysis',
analysis: 'Log analysis',
aiops: 'AIOps',
settings: 'General',
ldap: 'LDAP',
registry: 'Image Registry',
+98
View File
@@ -0,0 +1,98 @@
export default {
title: '智能运维',
subtitle: '用大模型解读日志信号:先看统计、再看结论,全程不把日志原文发出去',
tabs: {
analyze: '日志分析',
config: '模型配置',
history: '分析历史',
},
analyze: {
source: '数据源',
range: '时间范围',
question: '补充问题(可选)',
questionPlaceholder: '例如:这次日志量上涨是不是和发布有关?',
run: '开始分析',
running: '分析中…',
runHint: '只读取索引与预聚合统计(不扫日志内容),通常 3~15 秒',
empty: '选好数据源和时间范围后点「开始分析」',
notEnabled: 'AI 分析未启用:请先到「模型配置」填写并开启',
lastRun: '本次消耗',
promptTokens: '输入 token',
completionTokens: '输出 token',
duration: '耗时',
viewPrompt: '查看发给模型的内容',
promptTitle: '发给模型的完整内容(已脱敏)',
promptHint: '日志里出现的密码、Token、邮箱、手机号已替换为占位符;内网 IP 与端口保留用于排查。',
},
result: {
summary: '整体判断',
findings: '结论与证据',
actions: '建议动作',
unknowns: '无法判断的部分',
severityHigh: '高',
severityMedium: '中',
severityLow: '低',
noFindings: '没有发现明显问题',
noActions: '暂无建议',
noUnknowns: '无',
},
signals: {
title: '本次使用的信号',
volume: '日志体积',
entries: '日志条数',
apps: '应用排行',
baseline: '基线对比',
alerts: '窗口内告警',
trend: '体积趋势',
exact: '精确',
estimated: '估算',
none: '无',
},
config: {
enabled: '启用 AI 分析',
enabledHint: '关闭后「开始分析」会直接提示未启用,不影响其它功能',
provider: '协议',
providerHint: '仅支持 OpenAI 兼容接口:DeepSeek / 通义 / 内网 vLLM / Ollama(/v1) 都可',
baseUrl: 'Base URL',
baseUrlHint: '例如 https://api.deepseek.com/v1 或 http://172.19.16.10:8000/v1',
apiKey: 'API Key',
apiKeyHint: '保存后只显示前后几位;留空表示不修改。内网无鉴权服务可填任意占位值',
model: '模型名',
modelHint: '例如 deepseek-chat / qwen2.5:14b',
temperature: '温度',
temperatureHint: '分析场景建议 0~0.2,越小越稳定',
maxTokens: '单次输出上限',
timeoutSec: '超时(秒)',
dailyTokenCap: '每用户每日 token 上限',
dailyTokenCapHint: '0 = 不限制。这是成本闸门,超出当天直接拒绝分析',
redactExtra: '额外脱敏规则',
redactExtraHint: '每行一个正则,命中的内容替换为 <REDACTED>。内置规则已覆盖密码/Token/邮箱/手机号',
save: '保存配置',
test: '测试连接',
testing: '测试中…',
testOk: '连接正常,模型回复:{{reply}}',
usedToday: '今日已用 {{used}} tokens',
},
history: {
time: '时间',
user: '用户',
source: '数据源',
status: '状态',
tokens: 'Token',
duration: '耗时',
range: '范围',
empty: '还没有分析记录',
detail: '分析详情',
statusOk: '成功',
statusFailed: '失败',
statusQuota: '超出配额',
},
commonEmpty: '暂无数据',
}
+1
View File
@@ -17,6 +17,7 @@ export default {
logs: '日志查询',
alerts: '日志告警',
analysis: '日志分析',
aiops: '智能运维',
settings: '通用配置',
ldap: 'LDAP 接入',
registry: '默认镜像源',
+722
View File
@@ -0,0 +1,722 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Loader2, Play, Save, Sparkles, PlugZap, History, ShieldCheck } from 'lucide-react'
import { toast } from 'sonner'
import { PageContainer } from '@/components/PageContainer'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { aiApi, type AIAnalyzeResponse, type AIConfig, type AIConfigResponse, type AIRun } from '@/api/ai'
import { logsApi, type LogSource } from '@/api/logs'
const RANGE_PRESETS = [
{ key: '15m', minutes: 15 },
{ key: '1h', minutes: 60 },
{ key: '6h', minutes: 360 },
{ key: '24h', minutes: 1440 },
] as const
function fmtBytes(bytes: number): string {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = bytes
let index = 0
while (value >= 1024 && index < units.length - 1) {
value /= 1024
index += 1
}
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`
}
function fmtNum(value: number): string {
if (value >= 1e8) return `${(value / 1e8).toFixed(2)} 亿`
if (value >= 1e4) return `${(value / 1e4).toFixed(1)} 万`
return String(value)
}
export default function AiopsPage() {
const { t } = useTranslation('aiops')
const [tab, setTab] = useState('analyze')
// ── 分析 ──
const [sources, setSources] = useState<LogSource[]>([])
const [sourceId, setSourceId] = useState(0)
const [rangeKey, setRangeKey] = useState<(typeof RANGE_PRESETS)[number]['key']>('15m')
const [question, setQuestion] = useState('')
const [running, setRunning] = useState(false)
const [outcome, setOutcome] = useState<AIAnalyzeResponse | null>(null)
const [promptOpen, setPromptOpen] = useState(false)
const [enabled, setEnabled] = useState(false)
// ── 配置 ──
const [config, setConfig] = useState<AIConfig | null>(null)
const [apiKeyInput, setApiKeyInput] = useState('')
const [redactText, setRedactText] = useState('')
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [usedToday, setUsedToday] = useState(0)
// ── 历史 ──
const [runs, setRuns] = useState<AIRun[]>([])
const [runTotal, setRunTotal] = useState(0)
const [runPage, setRunPage] = useState(1)
const [runsLoading, setRunsLoading] = useState(false)
const [detail, setDetail] = useState<AIRun | null>(null)
const PAGE_SIZE = 20
useEffect(() => {
logsApi
.sources()
.then((res: { list?: LogSource[] }) => {
const list = res?.list ?? []
setSources(list)
if (list.length > 0) setSourceId((prev: number) => prev || list[0].id)
})
.catch(() => toast.error('数据源列表加载失败'))
}, [])
const loadConfig = useCallback(() => {
aiApi
.getConfig()
.then((res: AIConfigResponse) => {
setConfig(res.config)
setEnabled(Boolean(res.config?.enabled))
setRedactText((res.config?.redactExtra ?? []).join('\n'))
setUsedToday(res.tokenUsedToday ?? 0)
})
.catch(() => {
/* 非管理员访问配置会 403,属预期 */
})
}, [])
useEffect(() => {
loadConfig()
}, [loadConfig])
const loadRuns = useCallback(
(page: number) => {
setRunsLoading(true)
aiApi
.runs(page, PAGE_SIZE)
.then(res => {
setRuns(res.list ?? [])
setRunTotal(res.total ?? 0)
})
.catch(err => toast.error(err instanceof Error ? err.message : '历史加载失败'))
.finally(() => setRunsLoading(false))
},
[],
)
useEffect(() => {
if (tab === 'history') loadRuns(runPage)
}, [tab, runPage, loadRuns])
const rangeMinutes = useMemo(
() => RANGE_PRESETS.find(r => r.key === rangeKey)?.minutes ?? 15,
[rangeKey],
)
const runAnalysis = async () => {
if (!sourceId) {
toast.error('请先选择数据源')
return
}
const end = Date.now()
const start = end - rangeMinutes * 60 * 1000
setRunning(true)
setOutcome(null)
try {
const res = await aiApi.analyze({
sourceId,
start: start * 1e6,
end: end * 1e6,
question: question.trim() || undefined,
})
setOutcome(res)
loadConfig()
} catch (err) {
toast.error(err instanceof Error ? err.message : '分析失败')
} finally {
setRunning(false)
}
}
const saveConfig = async () => {
if (!config) return
setSaving(true)
try {
const res = await aiApi.saveConfig({
...config,
apiKey: apiKeyInput.trim(),
redactExtra: redactText
.split('\n')
.map(line => line.trim())
.filter(Boolean),
})
setConfig(res.config)
setApiKeyInput('')
toast.success('配置已保存')
} catch (err) {
toast.error(err instanceof Error ? err.message : '保存失败')
} finally {
setSaving(false)
}
}
const testConnection = async () => {
setTesting(true)
try {
const res = await aiApi.testConfig({
baseUrl: config?.baseUrl,
model: config?.model,
apiKey: apiKeyInput.trim() || undefined,
})
toast.success(t('config.testOk', { reply: res.reply || 'OK' }))
} catch (err) {
toast.error(err instanceof Error ? err.message : '连接失败')
} finally {
setTesting(false)
}
}
const severityColor = (severity: string) => {
if (severity === 'high') return 'bg-destructive/15 text-destructive'
if (severity === 'medium') return 'bg-warning/15 text-warning'
return 'bg-muted text-muted-foreground'
}
const totalPages = Math.max(1, Math.ceil(runTotal / PAGE_SIZE))
return (
<PageContainer title={t('title')} description={t('subtitle')}>
<Tabs value={tab} onValueChange={setTab} className="space-y-4">
<TabsList>
<TabsTrigger value="analyze">
<Sparkles className="mr-1.5 h-4 w-4" />
{t('tabs.analyze')}
</TabsTrigger>
<TabsTrigger value="config">
<PlugZap className="mr-1.5 h-4 w-4" />
{t('tabs.config')}
</TabsTrigger>
<TabsTrigger value="history">
<History className="mr-1.5 h-4 w-4" />
{t('tabs.history')}
</TabsTrigger>
</TabsList>
{/* ─────────── 分析 ─────────── */}
<TabsContent value="analyze" className="space-y-4">
{!enabled && (
<div className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-sm">
{t('analyze.notEnabled')}
</div>
)}
<Card>
<CardContent className="flex flex-wrap items-end gap-3 pt-4">
<div className="min-w-40 space-y-1">
<Label className="text-xs">{t('analyze.source')}</Label>
<select
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={sourceId}
onChange={e => setSourceId(Number(e.target.value))}
>
{sources.map(s => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label className="text-xs">{t('analyze.range')}</Label>
<div className="flex gap-1">
{RANGE_PRESETS.map(preset => (
<Button
key={preset.key}
type="button"
size="sm"
variant={rangeKey === preset.key ? 'default' : 'outline'}
className="h-9 px-3 text-xs"
onClick={() => setRangeKey(preset.key)}
>
{preset.key}
</Button>
))}
</div>
</div>
<div className="min-w-64 flex-1 space-y-1">
<Label className="text-xs">{t('analyze.question')}</Label>
<Input
value={question}
placeholder={t('analyze.questionPlaceholder')}
onChange={e => setQuestion(e.target.value)}
className="h-9"
/>
</div>
<Button onClick={runAnalysis} disabled={running || !enabled} className="h-9">
{running ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> : <Play className="mr-1.5 h-4 w-4" />}
{running ? t('analyze.running') : t('analyze.run')}
</Button>
</CardContent>
<div className="px-4 pb-3 text-xs text-muted-foreground">{t('analyze.runHint')}</div>
</Card>
{!outcome && !running && (
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">{t('analyze.empty')}</CardContent>
</Card>
)}
{running && (
<Card>
<CardContent className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('analyze.running')}
</CardContent>
</Card>
)}
{outcome && (
<>
{/* 本次消耗:让成本和延迟是可见的 */}
<div className="flex flex-wrap items-center gap-4 rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
<span>
{t('analyze.lastRun')}:{t('analyze.promptTokens')} {fmtNum(outcome.run.promptTokens)} ·{' '}
{t('analyze.completionTokens')} {fmtNum(outcome.run.completionTokens)} · {t('analyze.duration')}{' '}
{(outcome.run.durationMs / 1000).toFixed(1)}s
</span>
<Button variant="link" size="sm" className="h-auto p-0 text-xs" onClick={() => setPromptOpen(true)}>
{t('analyze.viewPrompt')}
</Button>
</div>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">{t('result.summary')}</CardTitle>
</CardHeader>
<CardContent className="whitespace-pre-wrap text-sm leading-relaxed">
{outcome.result.summary || t('commonEmpty')}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">{t('result.findings')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{(outcome.result.findings ?? []).length === 0 && (
<p className="text-sm text-muted-foreground">{t('result.noFindings')}</p>
)}
{(outcome.result.findings ?? []).map((finding, index) => (
<div key={index} className="rounded-md border p-3">
<div className="flex items-center gap-2">
<Badge className={`text-[10px] ${severityColor(finding.severity)}`}>
{finding.severity === 'high'
? t('result.severityHigh')
: finding.severity === 'medium'
? t('result.severityMedium')
: t('result.severityLow')}
</Badge>
<span className="text-sm font-medium">{finding.title}</span>
</div>
<p className="mt-1.5 text-xs text-muted-foreground">{finding.evidence}</p>
</div>
))}
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">{t('result.actions')}</CardTitle>
</CardHeader>
<CardContent>
{(outcome.result.actions ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">{t('result.noActions')}</p>
) : (
<ol className="list-decimal space-y-1 pl-5 text-sm">
{outcome.result.actions.map((action, index) => (
<li key={index}>{action}</li>
))}
</ol>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-1.5 text-base">
<ShieldCheck className="h-4 w-4" />
{t('result.unknowns')}
</CardTitle>
</CardHeader>
<CardContent>
{(outcome.result.unknowns ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">{t('result.noUnknowns')}</p>
) : (
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
{outcome.result.unknowns.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
{/* 信号明细:让用户知道结论是基于什么算出来的 */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">{t('signals.title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
<span>
{t('signals.volume')}:<span className="text-foreground">{fmtBytes(outcome.signals.totalBytes)}</span>
</span>
<span>
{t('signals.entries')}:
<span className="text-foreground">
{outcome.signals.entriesExact ? fmtNum(outcome.signals.totalEntries) : t('signals.estimated')}
</span>
</span>
{outcome.signals.baseline && (
<span>
{t('signals.baseline')}:
<span
className={
outcome.signals.baseline.deltaPercent > 20
? 'text-destructive'
: outcome.signals.baseline.deltaPercent < -20
? 'text-warning'
: 'text-foreground'
}
>
{outcome.signals.baseline.deltaPercent > 0 ? '+' : ''}
{outcome.signals.baseline.deltaPercent.toFixed(1)}%
</span>
</span>
)}
<span>
{t('signals.alerts')}:{outcome.signals.alerts.length}
</span>
</div>
{outcome.signals.apps.length > 0 && (
<div className="overflow-hidden rounded-md border">
<table className="w-full text-xs">
<thead className="bg-muted/40 text-muted-foreground">
<tr>
<th className="px-2 py-1.5 text-left">#</th>
<th className="px-2 py-1.5 text-left">{t('signals.apps')}</th>
<th className="px-2 py-1.5 text-right">{t('signals.entries')}</th>
<th className="px-2 py-1.5 text-right">%</th>
</tr>
</thead>
<tbody>
{outcome.signals.apps.slice(0, 10).map((app, index) => (
<tr key={`${app.namespace}/${app.app}`} className="border-t">
<td className="px-2 py-1.5 text-muted-foreground">{index + 1}</td>
<td className="px-2 py-1.5 font-mono">
{app.app || '-'}
<span className="ml-1 text-muted-foreground">/{app.namespace}</span>
</td>
<td className="px-2 py-1.5 text-right tabular-nums">{fmtNum(app.entries)}</td>
<td className="px-2 py-1.5 text-right tabular-nums text-muted-foreground">{app.share}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{(outcome.warnings ?? []).length > 0 && (
<ul className="list-disc space-y-0.5 pl-5 text-xs text-muted-foreground">
{outcome.warnings.map((warning, index) => (
<li key={index}>{warning}</li>
))}
</ul>
)}
</CardContent>
</Card>
</>
)}
</TabsContent>
{/* ─────────── 模型配置 ─────────── */}
<TabsContent value="config" className="space-y-4">
{!config ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
仅管理员可修改模型配置
</CardContent>
</Card>
) : (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">模型接入(OpenAI 兼容)</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-3">
<Switch
checked={Boolean(config.enabled)}
onCheckedChange={checked => setConfig({ ...config, enabled: checked })}
/>
<div>
<div className="text-sm">{t('config.enabled')}</div>
<div className="text-xs text-muted-foreground">{t('config.enabledHint')}</div>
</div>
<span className="ml-auto text-xs text-muted-foreground">
{t('config.usedToday', { used: fmtNum(usedToday) })}
</span>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">{t('config.baseUrl')}</Label>
<Input
value={config.baseUrl}
placeholder="https://api.deepseek.com/v1"
onChange={e => setConfig({ ...config, baseUrl: e.target.value })}
/>
<p className="text-[11px] text-muted-foreground">{t('config.baseUrlHint')}</p>
</div>
<div className="space-y-1">
<Label className="text-xs">{t('config.model')}</Label>
<Input
value={config.model}
placeholder="deepseek-chat"
onChange={e => setConfig({ ...config, model: e.target.value })}
/>
<p className="text-[11px] text-muted-foreground">{t('config.modelHint')}</p>
</div>
<div className="space-y-1">
<Label className="text-xs">{t('config.apiKey')}</Label>
<Input
type="password"
value={apiKeyInput}
placeholder={config.apiKey || 'sk-...'}
onChange={e => setApiKeyInput(e.target.value)}
/>
<p className="text-[11px] text-muted-foreground">{t('config.apiKeyHint')}</p>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="space-y-1">
<Label className="text-xs">{t('config.temperature')}</Label>
<Input
type="number"
step="0.1"
min="0"
max="1"
value={config.temperature}
onChange={e => setConfig({ ...config, temperature: Number(e.target.value) })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">{t('config.maxTokens')}</Label>
<Input
type="number"
value={config.maxTokens}
onChange={e => setConfig({ ...config, maxTokens: Number(e.target.value) })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">{t('config.timeoutSec')}</Label>
<Input
type="number"
value={config.timeoutSec}
onChange={e => setConfig({ ...config, timeoutSec: Number(e.target.value) })}
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">{t('config.dailyTokenCap')}</Label>
<Input
type="number"
value={config.dailyTokenCap}
onChange={e => setConfig({ ...config, dailyTokenCap: Number(e.target.value) })}
/>
<p className="text-[11px] text-muted-foreground">{t('config.dailyTokenCapHint')}</p>
</div>
<div className="space-y-1">
<Label className="text-xs">{t('config.redactExtra')}</Label>
<Textarea
rows={3}
value={redactText}
onChange={e => setRedactText(e.target.value)}
className="font-mono text-xs"
/>
<p className="text-[11px] text-muted-foreground">{t('config.redactExtraHint')}</p>
</div>
</div>
{/* 顺序按实际操作流:先「测试连接」确认能通,再「保存配置」 */}
<div className="flex gap-2">
<Button variant="outline" onClick={testConnection} disabled={testing}>
{testing ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<PlugZap className="mr-1.5 h-4 w-4" />
)}
{testing ? t('config.testing') : t('config.test')}
</Button>
<Button onClick={saveConfig} disabled={saving}>
{saving ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> : <Save className="mr-1.5 h-4 w-4" />}
{t('config.save')}
</Button>
</div>
</CardContent>
</Card>
)}
</TabsContent>
{/* ─────────── 历史 ─────────── */}
<TabsContent value="history" className="space-y-3">
<Card>
<CardContent className="p-0">
<table className="w-full text-xs">
<thead className="bg-muted/40 text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left">{t('history.time')}</th>
<th className="px-3 py-2 text-left">{t('history.user')}</th>
<th className="px-3 py-2 text-left">{t('history.source')}</th>
<th className="px-3 py-2 text-right">{t('history.range')}</th>
<th className="px-3 py-2 text-left">{t('history.status')}</th>
<th className="px-3 py-2 text-right">{t('history.tokens')}</th>
<th className="px-3 py-2 text-right">{t('history.duration')}</th>
</tr>
</thead>
<tbody>
{runsLoading && (
<tr>
<td colSpan={7} className="px-3 py-6 text-center text-muted-foreground">
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
</td>
</tr>
)}
{!runsLoading && runs.length === 0 && (
<tr>
<td colSpan={7} className="px-3 py-6 text-center text-muted-foreground">
{t('history.empty')}
</td>
</tr>
)}
{!runsLoading &&
runs.map(run => (
<tr
key={run.id}
className="cursor-pointer border-t hover:bg-muted/40"
onClick={() => setDetail(run)}
>
<td className="px-3 py-2 text-muted-foreground">
{new Date(run.createdAt).toLocaleString()}
</td>
<td className="px-3 py-2">{run.username || '-'}</td>
<td className="px-3 py-2">{run.sourceName}</td>
<td className="px-3 py-2 text-right tabular-nums">{run.rangeMinutes}m</td>
<td className="px-3 py-2">
<Badge
className={`text-[10px] ${
run.status === 'ok'
? 'bg-success/15 text-success'
: run.status === 'quota-exceeded'
? 'bg-warning/15 text-warning'
: 'bg-destructive/15 text-destructive'
}`}
>
{run.status === 'ok'
? t('history.statusOk')
: run.status === 'quota-exceeded'
? t('history.statusQuota')
: t('history.statusFailed')}
</Badge>
</td>
<td className="px-3 py-2 text-right tabular-nums">
{fmtNum(run.promptTokens + run.completionTokens)}
</td>
<td className="px-3 py-2 text-right tabular-nums">{(run.durationMs / 1000).toFixed(1)}s</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
共 {runTotal} 条 · 第 {runPage}/{totalPages} 页
</span>
<div className="flex gap-1">
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
disabled={runPage <= 1}
onClick={() => setRunPage(page => Math.max(1, page - 1))}
>
上一页
</Button>
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
disabled={runPage >= totalPages}
onClick={() => setRunPage(page => page + 1)}
>
下一页
</Button>
</div>
</div>
</TabsContent>
</Tabs>
{/* 发给模型的完整内容 */}
<Dialog open={promptOpen} onOpenChange={setPromptOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{t('analyze.promptTitle')}</DialogTitle>
</DialogHeader>
<p className="text-xs text-muted-foreground">{t('analyze.promptHint')}</p>
<pre className="max-h-[60vh] overflow-auto whitespace-pre-wrap rounded-md bg-muted/40 p-3 text-xs">
{outcome?.prompt ?? ''}
</pre>
</DialogContent>
</Dialog>
{/* 历史详情 */}
<Dialog open={Boolean(detail)} onOpenChange={open => !open && setDetail(null)}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{t('history.detail')}</DialogTitle>
</DialogHeader>
{detail && (
<div className="space-y-3 text-sm">
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
<span>{new Date(detail.createdAt).toLocaleString()}</span>
<span>{detail.username}</span>
<span>{detail.sourceName}</span>
<span>{detail.model}</span>
<span>
{detail.promptTokens}+{detail.completionTokens} tokens
</span>
<span>{(detail.durationMs / 1000).toFixed(1)}s</span>
</div>
{detail.error && <p className="text-xs text-destructive">{detail.error}</p>}
<pre className="max-h-[55vh] overflow-auto whitespace-pre-wrap rounded-md bg-muted/40 p-3 text-xs">
{detail.promptPayload || detail.conclusion}
</pre>
</div>
)}
</DialogContent>
</Dialog>
</PageContainer>
)
}
+4
View File
@@ -15,6 +15,7 @@ import {
ScrollText,
BellRing,
BarChart3,
Sparkles,
History,
Boxes,
ShieldCheck,
@@ -38,6 +39,7 @@ const LogsPage = lazy(() => import('@/pages/logs'));
const AlertsPage = lazy(() => import('@/pages/alerts'));
const UsersPage = lazy(() => import('@/pages/users'));
const AnalysisPage = lazy(() => import('@/pages/analysis'));
const AiopsPage = lazy(() => import('@/pages/aiops'));
const AuditPage = lazy(() => import('@/pages/audit'));
export const routes: AppRoute[] = [
@@ -70,6 +72,8 @@ export const routes: AppRoute[] = [
// ── 日志查询 / 日志分析:高频入口,保持独立菜单项(不折叠)──
{ path: '/logs', component: LogsPage, auth: true, meta: { title: 'menu.logs', icon: ScrollText, order: 5 } },
{ path: '/analysis', component: AnalysisPage, auth: true, meta: { title: 'menu.analysis', icon: BarChart3, order: 6, adminOnly: true } },
// 智能运维:AI 日志分析。紧跟日志分析,便于"先看统计、再问 AI"的动线
{ path: '/aiops', component: AiopsPage, auth: true, meta: { title: 'menu.aiops', icon: Sparkles, order: 6, adminOnly: true } },
// ── 告警:独立入口(要盯的,不能被折叠) ──
// 告警仅管理员:普通用户按「查询授权」只看指定的日志(菜单只剩日志查询)。