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([]) const [sourceId, setSourceId] = useState(0) const [rangeKey, setRangeKey] = useState<(typeof RANGE_PRESETS)[number]['key']>('15m') const [apps, setApps] = useState([]) const [app, setApp] = useState('') const [question, setQuestion] = useState('') const [running, setRunning] = useState(false) const [outcome, setOutcome] = useState(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(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([]) const [runTotal, setRunTotal] = useState(0) const [runPage, setRunPage] = useState(1) const [runsLoading, setRunsLoading] = useState(false) const [detail, setDetail] = useState(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 return ( {t('tabs.analyze')} {t('tabs.config')} {t('tabs.history')} {/* ─────────── 分析 ─────────── */} {!enabled && (
{t('analyze.notEnabled')}
)}
{RANGE_PRESETS.map(preset => ( ))}
{needApp &&

{t('analyze.appScopeHint')}

}
setQuestion(e.target.value)} className="h-9" />

{t('analyze.runHint')}

{!outcome && !running && (

{t('analyze.empty')}

)} {running && (

{t('analyze.running')}

{t('analyze.runHint')}

)} {outcome && ( <> {/* 本次消耗:让成本和延迟是可见的 */}
{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
{t('result.summary')} {outcome.result.summary || t('commonEmpty')} {t('result.findings')} {(outcome.result.findings ?? []).length === 0 && (

{t('result.noFindings')}

)} {(outcome.result.findings ?? []).map((finding, index) => (
{finding.severity === 'high' ? t('result.severityHigh') : finding.severity === 'medium' ? t('result.severityMedium') : t('result.severityLow')} {finding.title}

{finding.evidence}

))}
{t('result.actions')} {(outcome.result.actions ?? []).length === 0 ? (

{t('result.noActions')}

) : (
    {outcome.result.actions.map((action, index) => (
  1. {action}
  2. ))}
)}
{t('result.unknowns')} {(outcome.result.unknowns ?? []).length === 0 ? (

{t('result.noUnknowns')}

) : (
    {outcome.result.unknowns.map((item, index) => (
  • {item}
  • ))}
)}
{/* 信号明细:让用户知道结论是基于什么算出来的 */} {t('signals.title')}
{t('signals.volume')}:{fmtBytes(outcome.signals.totalBytes)} {t('signals.entries')}: {outcome.signals.entriesExact ? fmtNum(outcome.signals.totalEntries) : t('signals.estimated')} {outcome.signals.baseline && ( {t('signals.baseline')}: 20 ? 'text-destructive' : outcome.signals.baseline.deltaPercent < -20 ? 'text-warning' : 'text-foreground' } > {outcome.signals.baseline.deltaPercent > 0 ? '+' : ''} {outcome.signals.baseline.deltaPercent.toFixed(1)}% )} {t('signals.alerts')}:{outcome.signals.alerts.length}
{outcome.signals.apps.length > 0 && (
{outcome.signals.apps.slice(0, 10).map((app, index) => ( ))}
# {t('signals.apps')} {t('signals.entries')} %
{index + 1} {app.app || '-'} /{app.namespace} {fmtNum(app.entries)} {app.share}
)} {(outcome.warnings ?? []).length > 0 && (
    {outcome.warnings.map((warning, index) => (
  • {warning}
  • ))}
)}
)}
{/* ─────────── 模型配置 ─────────── */} {!config ? ( 仅管理员可修改模型配置 ) : ( 模型接入(OpenAI 兼容)
setConfig({ ...config, enabled: checked })} />
{t('config.enabled')}
{t('config.enabledHint')}
{t('config.usedToday', { used: fmtNum(usedToday) })}

{t('config.providerSelectHint')}

{((LLM_PROVIDERS.find(p => p.id === matchProvider(config.baseUrl))?.models ?? ['deepseek-flash'])).includes(config.model) || !config.model ? ( ) : ( setConfig({ ...config, model: e.target.value })} /> )}

{t('config.modelHint')}

{matchProvider(config.baseUrl) === 'custom' && (
setConfig({ ...config, baseUrl: e.target.value })} />

{t('config.baseUrlHint')}

)}
setApiKeyInput(e.target.value)} />

{t('config.apiKeyHint')}

{t('config.advanced')}
setConfig({ ...config, temperature: Number(e.target.value) })} />
setConfig({ ...config, maxTokens: Number(e.target.value) })} />
setConfig({ ...config, timeoutSec: Number(e.target.value) })} />
setConfig({ ...config, dailyTokenCap: Number(e.target.value) })} />

{t('config.dailyTokenCapHint')}