refactor(ai): 砍掉 /aiops 整页,模型配置迁入系统设置,浮窗助手保留
CI / Backend (go vet + test) (push) Waiting to run
CI / Frontend (lint + build + test) (push) Waiting to run

评估结论落地:整页分析只喂聚合统计(模型看不到日志原文),结论价值低于 /analysis
图表自身;浮窗助手携带真实查询样本,价值成立。
- 删 /aiops 页面与路由(菜单去项);llmProviders 迁 lib 共用
- 模型配置重做为设置页「AI 模型接入」区:状态严谨映射——供应商/模型按已存
  baseUrl/model 正确回显(自定义供应商显示 BaseURL 与自定义模型输入,不再重置),
  API Key 掩码占位、留空或掩码保存均沿用旧值(不回传掩码)
- 后端删 /ai/analyze 与信号采集链路;/ai/chat 保留(上下文由前端日志样本注入)
This commit is contained in:
opsx committed 2026-11-01 00:46:38 +08:00
1 parent c216bdfdd4
commit fbbd3a3c27
13 files changed
+195 -911

No files matched your search

+1 -63
View File
@@ -1,9 +1,7 @@
package controllers
import (
"fmt"
"strconv"
"time"
"lokistack/models"
"lokistack/services"
@@ -81,56 +79,6 @@ func containsMask(s string) bool {
return false
}
// Analyze 执行一次日志分析。
//
// 时间范围在服务端收敛:最大 24 小时。理由不是性能(P0 全走索引,很快),
// 而是"范围越大,信号越笼统,结论越没用",且 token 会线性增长。
func (ctrl *AIController) Analyze(c *gin.Context) {
var req struct {
SourceID uint `json:"sourceId" binding:"required"`
Start int64 `json:"start" binding:"required"`
End int64 `json:"end" binding:"required"`
Question string `json:"question"`
App string `json:"app"`
}
if !utils.BindAndValidate(c, &req) {
return
}
src := services.GetLogSource(req.SourceID)
if src == nil {
utils.BadRequest(c, "数据源不存在")
return
}
from := time.Unix(0, req.Start)
to := time.Unix(0, req.End)
if !to.After(from) {
utils.BadRequest(c, "时间范围无效:结束时间必须晚于开始时间")
return
}
if to.Sub(from) > 24*time.Hour {
utils.BadRequest(c, fmt.Sprintf("时间范围过大(%d 分钟):AI 分析最多支持 24 小时,建议用 15~60 分钟", int(to.Sub(from).Minutes())))
return
}
// 长窗口必须指定应用:全命名空间聚合在 6h/24h 下数据量过大,查询会拖垮 Loki
if to.Sub(from) > time.Hour && req.App == "" {
utils.BadRequest(c, "超过 1 小时的分析必须指定应用(app):全命名空间长窗口聚合过慢,请先在应用排行里选一个 app 缩小范围")
return
}
userID := c.GetUint("userId")
username := c.GetString("username")
admin := c.GetString("role") == "admin"
cfg := services.GetAIConfig()
outcome, err := services.RunAIAnalysis(c.Request.Context(), cfg, src, userID, username, admin, from, to, req.Question, req.App)
if err != nil {
// 未配置模型 / 配额用尽 / 上游报错:都给出具体原因,不包装成 500
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, outcome)
}
// ListRuns 审计历史(分页)。
func (ctrl *AIController) ListRuns(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
@@ -157,25 +105,15 @@ func (ctrl *AIController) GetRun(c *gin.Context) {
// Chat 全站 AI 助手浮窗(多轮对话 + 页面上下文注入;Pro 闸门)
func (ctrl *AIController) Chat(c *gin.Context) {
var req struct {
SourceID uint `json:"sourceId"`
History []services.ChatMessage `json:"history" binding:"required,min=1,max=40"`
Page string `json:"page"`
RangeMinutes int `json:"rangeMinutes"`
Context string `json:"context"`
}
if !utils.BindAndValidate(c, &req) {
return
}
cfg := services.GetAIConfig()
var src *models.LogSource
if req.SourceID > 0 {
src = services.GetLogSource(req.SourceID)
}
rangeMinutes := req.RangeMinutes
if rangeMinutes <= 0 || rangeMinutes > 120 {
rangeMinutes = 15
}
reply, err := services.RunAIChat(c.Request.Context(), cfg, src, c.GetUint("userId"), c.GetString("role") == "admin", req.History, req.Page, rangeMinutes, req.Context)
reply, err := services.RunAIChat(c.Request.Context(), cfg, c.GetUint("userId"), req.History, req.Page, req.Context)
if err != nil {
utils.Error(c, 400, err.Error())
return
-1
View File
@@ -130,7 +130,6 @@ func SetupRouter() *gin.Engine {
ai.GET("/config", middleware.AdminAuth(), aiCtrl.GetConfig)
ai.PUT("/config", middleware.AdminAuth(), aiCtrl.SaveConfig)
ai.POST("/config/test", middleware.AdminAuth(), aiCtrl.TestConfig)
ai.POST("/analyze", middleware.RequirePro(), aiCtrl.Analyze)
ai.POST("/chat", middleware.RequirePro(), aiCtrl.Chat)
ai.GET("/runs", aiCtrl.ListRuns)
ai.GET("/runs/:id", aiCtrl.GetRun)
+1 -21
View File
@@ -155,7 +155,7 @@ type ChatMessage struct {
// RunAIChat 全站 AI 助手浮窗:多轮对话 + 当前页面上下文注入(轻量信号摘要)。
// 与整页分析共享配置/配额/脱敏/Pro 闸门;context 里的 sourceId 可选,带了就注入日志摘要。
func RunAIChat(ctx context.Context, cfg models.AIConfig, src *models.LogSource, userID uint, admin bool, history []ChatMessage, page string, rangeMinutes int, pageContext string) (string, error) {
func RunAIChat(ctx context.Context, cfg models.AIConfig, userID uint, history []ChatMessage, page string, pageContext string) (string, error) {
if !cfg.Enabled {
return "", fmt.Errorf("AI 未启用:请先在「智能运维 → 模型配置」中开启并保存")
}
@@ -170,26 +170,6 @@ func RunAIChat(ctx context.Context, cfg models.AIConfig, src *models.LogSource,
- 被问"你是什么模型/版本"时,只答一句:"我是 LokiStack 内置运维助手,底层模型由管理员在「智能运维 → 模型配置」中指定。"然后引导回日志排查。
- 只讨论日志、Loki、采集、告警与平台使用;无关话题(写代码、闲聊、其它产品)用一句礼貌拒绝并引导回排查。
- 引用数据时只使用下方"日志概况"中的数字,不得编造。`
if src != nil && rangeMinutes > 0 {
end := time.Now()
from := end.Add(-time.Duration(rangeMinutes) * time.Minute)
if bundle, err := CollectAISignals(ctx, src, userID, admin, from, end, ""); err == nil {
system += "\n\n当前用户正在查看 " + page + " 页,近 " + fmt.Sprintf("%d", rangeMinutes) + " 分钟的日志概况:总量 " +
fmt.Sprintf("%d", bundle.TotalEntries) + " 条(估算)"
if len(bundle.Apps) > 0 {
system += ",Top 应用:"
for i, a := range bundle.Apps {
if i >= 3 {
break
}
system += fmt.Sprintf(" %s(%d)", a.App, a.Entries)
}
}
if len(bundle.Alerts) > 0 {
system += ";存在告警命中"
}
}
}
// 页面上下文(如 /logs 刚执行的查询与样本日志):截断并统一脱敏后并入 system
if trimmed := strings.TrimSpace(pageContext); trimmed != "" {
+2 -2
View File
@@ -6,8 +6,8 @@ import (
)
// 脱敏的两条底线:
// 1) 凭据必须挡住(否则把 key 发给外部模型);
// 2) 排查必需的信息必须留着(IP/端口/命名空间/错误码),
// 1. 凭据必须挡住(否则把 key 发给外部模型);
// 2. 排查必需的信息必须留着(IP/端口/命名空间/错误码),
// 否则模型给不出有用结论 —— 这比"脱得太干净"更常见。
func TestRedactText(t *testing.T) {
in := `2026-10-30 12:00:01 ERROR redis client: dial tcp 172.19.16.17:6379: connect: connection refused
+6
View File
@@ -70,6 +70,12 @@ export default {
},
config: {
sectionTitle: 'AI Model',
enabledBadge: 'Enabled',
disabledBadge: 'Disabled',
testFailed: 'Connection failed',
saved: 'Saved',
saveFailed: 'Save failed',
customModel: 'Custom model…',
advanced: 'Advanced',
providerSelectHint: 'Pick a provider to autofill endpoint and model',
-1
View File
@@ -17,7 +17,6 @@ export default {
logs: 'Log Query',
alerts: 'Alerts',
analysis: 'Log analysis',
aiops: 'AIOps',
settings: 'General',
ldap: 'LDAP',
registry: 'Image Registry',
+6
View File
@@ -70,6 +70,12 @@ export default {
},
config: {
sectionTitle: 'AI 模型接入',
enabledBadge: '已启用',
disabledBadge: '未启用',
testFailed: '连接失败',
saved: '配置已保存',
saveFailed: '保存失败',
customModel: '自定义模型…',
advanced: '高级设置',
providerSelectHint: '选择供应商自动填入端点与推荐模型',
-1
View File
@@ -17,7 +17,6 @@ export default {
logs: '日志查询',
alerts: '日志告警',
analysis: '日志分析',
aiops: '智能运维',
settings: '通用配置',
ldap: 'LDAP 接入',
registry: '默认镜像源',
File renamed without changes.
-818
View File
@@ -1,818 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { LLM_PROVIDERS, matchProvider } from './llmProviders'
import { Loader2, Play, Save, Sparkles, PlugZap, History, ShieldCheck } from 'lucide-react'
import { toast } from 'sonner'
import { ProGate } from '@/components/ProGate'
import { useLicense } from '@/hooks/useLicense'
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
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 { pro } = useLicense()
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 [apps, setApps] = useState<string[]>([])
const [app, setApp] = useState('')
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)
// app 候选:长窗口(>1h)必须指定应用收窄聚合范围
useEffect(() => {
setApp('')
setApps([])
if (!sourceId) return
let active = true
logsApi.labelValues(sourceId, 'app')
.then(list => { if (active) setApps(Array.isArray(list) ? list : []) })
.catch(() => { if (active) setApps([]) })
return () => { active = false }
}, [sourceId])
const rangeMinutes = RANGE_PRESETS.find(r => r.key === rangeKey)?.minutes ?? 15
const needApp = rangeMinutes > 60
// ── 配置 ──
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) => {
const cfg = res.config
// 首次配置默认 DeepSeek + 最新 flash:用户只需填 API Key
if (cfg && !cfg.baseUrl && !cfg.model) {
const deepseek = LLM_PROVIDERS.find(p => p.id === 'deepseek')!
cfg.baseUrl = deepseek.endpoint
cfg.model = 'deepseek-flash'
}
setConfig(cfg)
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 runAnalysis = async () => {
if (!sourceId) {
toast.error('请先选择数据源')
return
}
if (needApp && !app) {
toast.error('超过 1 小时的分析必须指定应用:全命名空间长窗口聚合过慢')
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,
app: app || 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)
setEnabled(Boolean(res.config?.enabled))
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', { model: res.model, latency: (res.latencyMs / 1000).toFixed(1), tokens: res.tokensUsed }))
} 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))
if (!pro) return <ProGate featureKey="ai_ops" />
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="flex flex-wrap items-center gap-3 rounded-lg border border-warning/40 bg-warning/10 px-4 py-3 text-sm">
<Sparkles className="size-4 shrink-0 text-warning" />
<span className="flex-1">{t('analyze.notEnabled')}</span>
<Button size="sm" variant="outline" onClick={() => setTab('config')}>{t('analyze.goConfig')}</Button>
</div>
)}
<Card>
<CardContent className="pt-4">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-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="space-y-1">
<Label className="text-xs">
{t('analyze.appScope')}
{needApp && <span className="ml-1 text-destructive">*</span>}
</Label>
<select
aria-label={t('analyze.appScope')}
className="h-9 min-w-40 rounded-md border bg-background px-3 text-sm"
value={app}
onChange={e => setApp(e.target.value)}
>
<option value="">{needApp ? t('analyze.appRequired') : t('analyze.allApps')}</option>
{apps.map(a => <option key={a} value={a}>{a}</option>)}
</select>
{needApp && <p className="text-[11px] text-warning">{t('analyze.appScopeHint')}</p>}
</div>
<div className="space-y-1 xl:col-span-2">
<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>
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-2 border-t pt-3">
<p className="text-xs text-muted-foreground">{t('analyze.runHint')}</p>
<Button onClick={runAnalysis} disabled={running || !enabled || (needApp && !app)} 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>
</div>
</CardContent>
</Card>
{!outcome && !running && (
<Card>
<CardContent className="flex flex-col items-center justify-center gap-2 py-14 text-center">
<Sparkles className="size-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">{t('analyze.empty')}</p>
</CardContent>
</Card>
)}
{running && (
<Card>
<CardContent className="flex flex-col items-center justify-center gap-2 py-14 text-center">
<Loader2 className="size-7 animate-spin text-primary" />
<p className="text-sm font-medium">{t('analyze.running')}</p>
<p className="text-xs text-muted-foreground">{t('analyze.runHint')}</p>
</CardContent>
</Card>
)}
{outcome && (
<>
{/* 本次消耗:让成本和延迟是可见的 */}
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 rounded-lg border bg-muted/30 px-4 py-2.5 text-xs">
<span className="font-medium text-foreground">{t('analyze.lastRun')}</span>
<span className="text-muted-foreground">{t('analyze.promptTokens')} <span className="tabular-nums text-foreground">{fmtNum(outcome.run.promptTokens)}</span></span>
<span className="text-muted-foreground">{t('analyze.completionTokens')} <span className="tabular-nums text-foreground">{fmtNum(outcome.run.completionTokens)}</span></span>
<span className="text-muted-foreground">{t('analyze.duration')} <span className="tabular-nums text-foreground">{(outcome.run.durationMs / 1000).toFixed(1)}s</span></span>
<Button variant="link" size="sm" className="ml-auto 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.provider')}</Label>
<Select
value={matchProvider(config.baseUrl)}
onValueChange={value => {
const p = LLM_PROVIDERS.find(x => x.id === value)
if (!p) return
setConfig({ ...config, baseUrl: p.endpoint, model: p.models[0] && value !== 'custom' ? p.models[0] : config.model })
}}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{LLM_PROVIDERS.map(p => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">{t('config.providerSelectHint')}</p>
</div>
<div className="space-y-1">
<Label className="text-xs">{t('config.model')}</Label>
{((LLM_PROVIDERS.find(p => p.id === matchProvider(config.baseUrl))?.models ?? ['deepseek-flash'])).includes(config.model) || !config.model ? (
<Select
value={config.model || ((LLM_PROVIDERS.find(p => p.id === matchProvider(config.baseUrl))?.models ?? ['deepseek-flash']))[0]}
onValueChange={value => {
if (value === '__custom__') { setConfig({ ...config, model: '' }); return }
setConfig({ ...config, model: value })
}}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{((LLM_PROVIDERS.find(p => p.id === matchProvider(config.baseUrl))?.models ?? ['deepseek-flash'])).map(m => (
<SelectItem key={m} value={m}>{m}</SelectItem>
))}
<SelectItem value="__custom__">{t('config.customModel')}</SelectItem>
</SelectContent>
</Select>
) : (
<Input value={config.model} placeholder="model-name" onChange={e => setConfig({ ...config, model: e.target.value })} />
)}
<p className="text-[11px] text-muted-foreground">{t('config.modelHint')}</p>
</div>
{matchProvider(config.baseUrl) === 'custom' && (
<div className="space-y-1 sm:col-span-2">
<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.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>
<details className="sm:col-span-2 rounded-lg border px-3 py-2">
<summary className="cursor-pointer text-xs font-medium text-muted-foreground">{t('config.advanced')}</summary>
<div className="mt-3 grid gap-4">
<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>
</details>
</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>
)
}
@@ -0,0 +1,177 @@
import { memo, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Bot, Loader2, PlugZap, Save } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { aiApi, type AIConfig } from '@/api/ai'
import { LLM_PROVIDERS, matchProvider } from '@/lib/llmProviders'
import { getUser } from '@/lib/auth-storage'
const isMasked = (v: string) => v.includes('***')
/** 模型接入配置(原 /aiops 模型配置迁入;仅管理员可改) */
export const AiModelSection = memo(function AiModelSection() {
const { t } = useTranslation('aiops')
const isAdmin = getUser()?.role === 'admin'
const [config, setConfig] = useState<AIConfig | null>(null)
const [apiKeyInput, setApiKeyInput] = useState('')
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const loadConfig = useCallback(() => {
aiApi.getConfig()
.then(res => {
const cfg = res.config
// 首次配置默认 DeepSeek + flash:只需填 Key
if (cfg && !cfg.baseUrl && !cfg.model) {
const deepseek = LLM_PROVIDERS.find(p => p.id === 'deepseek')!
cfg.baseUrl = deepseek.endpoint
cfg.model = 'deepseek-flash'
}
setConfig(cfg)
})
.catch(() => setConfig(null))
}, [])
useEffect(() => { if (isAdmin) loadConfig() }, [isAdmin, loadConfig])
if (!isAdmin || !config) return null
const providerId = matchProvider(config.baseUrl)
const provider = LLM_PROVIDERS.find(p => p.id === providerId)
const modelOptions = provider?.models ?? []
const modelIsPreset = modelOptions.includes(config.model)
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', { model: res.model, latency: (res.latencyMs / 1000).toFixed(1), tokens: res.tokensUsed }))
} catch (err) {
toast.error(err instanceof Error ? err.message : t('config.testFailed'))
} finally {
setTesting(false)
}
}
const save = async () => {
setSaving(true)
try {
const res = await aiApi.saveConfig({
...config,
// 掩码或留空都不回传 → 后端沿用已存 Key,不会把掩码存回去
apiKey: apiKeyInput.trim() && !isMasked(apiKeyInput) ? apiKeyInput.trim() : undefined,
})
setConfig(res.config)
setApiKeyInput('')
toast.success(t('config.saved'))
} catch (err) {
toast.error(err instanceof Error ? err.message : t('config.saveFailed'))
} finally {
setSaving(false)
}
}
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Bot className="size-4" />
{t('config.sectionTitle')}
<Badge variant={config.enabled ? 'default' : 'secondary'} className={config.enabled ? 'bg-success text-success-foreground' : ''}>
{config.enabled ? t('config.enabledBadge') : t('config.disabledBadge')}
</Badge>
</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 className="text-sm">{t('config.enabled')}</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">{t('config.provider')}</Label>
<Select
value={providerId}
onValueChange={value => {
const p = LLM_PROVIDERS.find(x => x.id === value)
if (!p) return
setConfig({
...config,
baseUrl: p.endpoint,
model: value !== 'custom' && p.models[0] ? p.models[0] : config.model,
})
}}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{LLM_PROVIDERS.map(p => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">{t('config.providerSelectHint')}</p>
</div>
<div className="space-y-1">
<Label className="text-xs">{t('config.model')}</Label>
{modelIsPreset || !config.model ? (
<Select
value={config.model || modelOptions[0] || ''}
onValueChange={value => {
if (value === '__custom__') { setConfig({ ...config, model: '' }); return }
setConfig({ ...config, model: value })
}}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{modelOptions.map(m => <SelectItem key={m} value={m}>{m}</SelectItem>)}
<SelectItem value="__custom__">{t('config.customModel')}</SelectItem>
</SelectContent>
</Select>
) : (
<Input value={config.model} placeholder="model-name" onChange={e => setConfig({ ...config, model: e.target.value })} />
)}
<p className="text-[11px] text-muted-foreground">{t('config.modelHint')}</p>
</div>
{providerId === 'custom' && (
<div className="space-y-1 sm:col-span-2">
<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.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>
<div className="flex justify-end gap-2 border-t pt-3">
<Button variant="outline" onClick={() => void testConnection()} disabled={testing || !config.baseUrl || !config.model}>
{testing ? <Loader2 className="mr-1.5 size-4 animate-spin" /> : <PlugZap className="mr-1.5 size-4" />}
{testing ? t('config.testing') : t('config.test')}
</Button>
<Button onClick={() => void save()} disabled={saving}>
{saving ? <Loader2 className="mr-1.5 size-4 animate-spin" /> : <Save className="mr-1.5 size-4" />}
{t('config.save')}
</Button>
</div>
</CardContent>
</Card>
)
})
+2
View File
@@ -13,6 +13,7 @@ import { PageContainer } from '@/components/PageContainer'
import { ThemeSection } from './components/ThemeSection'
import { RetentionSection } from './components/RetentionSection'
import { LicenseSection } from './components/LicenseSection'
import { AiModelSection } from './components/AiModelSection'
import { LanguageSection } from '@/i18n/LanguageSection'
import { Palette } from 'lucide-react'
@@ -71,6 +72,7 @@ const SettingsPage = memo(function SettingsPage() {
return (
<PageContainer title={t('title')}>
<LicenseSection />
<AiModelSection />
{/* 不再有内层 tab:LDAP / 镜像源 已拆成独立路由,
由左侧「系统设置」分组的顶层 tab 切换(单层 tab 更好理解)。 */}
<div className="space-y-6">
-4
View File
@@ -15,7 +15,6 @@ import {
ScrollText,
BellRing,
BarChart3,
Sparkles,
History,
Boxes,
ShieldCheck,
@@ -39,7 +38,6 @@ 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[] = [
@@ -72,8 +70,6 @@ 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, proOnly: true } },
// 智能运维:AI 日志分析。紧跟日志分析,便于"先看统计、再问 AI"的动线
{ path: '/aiops', component: AiopsPage, auth: true, meta: { title: 'menu.aiops', icon: Sparkles, order: 7.5, adminOnly: true, proOnly: true } },
// ── 告警:独立入口(要盯的,不能被折叠) ──
// 告警仅管理员:普通用户按「查询授权」只看指定的日志(菜单只剩日志查询)。