feat(aiops): 新增「智能运维」——AI 日志分析(P0,不扫日志内容)
=== 为什么这样设计(成本优先)===
全集群 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:
1 parent
3861b8b128
commit
7be41f0a6b
19 files changed
+2236
-1
No files matched your search
@@ -0,0 +1,149 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"lokistack/models"
|
||||||
|
"lokistack/services"
|
||||||
|
"lokistack/utils"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AIController AIOps:模型配置 + 日志分析 + 审计历史
|
||||||
|
type AIController struct{}
|
||||||
|
|
||||||
|
func NewAIController() *AIController { return &AIController{} }
|
||||||
|
|
||||||
|
// GetConfig 返回模型配置(APIKey 掩码)+ 今日已用 token。
|
||||||
|
func (ctrl *AIController) GetConfig(c *gin.Context) {
|
||||||
|
userID := c.GetUint("userId")
|
||||||
|
cfg := services.MaskedAIConfig()
|
||||||
|
utils.Success(c, gin.H{
|
||||||
|
"config": cfg,
|
||||||
|
"tokenUsedToday": services.AITokenUsedToday(userID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveConfig 保存模型配置。apiKey 传空或掩码 = 沿用旧值。
|
||||||
|
func (ctrl *AIController) SaveConfig(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
models.AIConfig
|
||||||
|
APIKey string `json:"apiKey"`
|
||||||
|
}
|
||||||
|
if !utils.BindAndValidate(c, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := services.SaveAIConfig(req.AIConfig, req.APIKey); err != nil {
|
||||||
|
utils.Error(c, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, gin.H{"config": services.MaskedAIConfig()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfig 连通性测试:用已保存(或本次提交)的配置发一句最小请求。
|
||||||
|
func (ctrl *AIController) TestConfig(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
APIKey string `json:"apiKey"`
|
||||||
|
BaseURL string `json:"baseUrl"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
}
|
||||||
|
_ = c.ShouldBindJSON(&req)
|
||||||
|
|
||||||
|
cfg := services.GetAIConfig()
|
||||||
|
if req.BaseURL != "" {
|
||||||
|
cfg.BaseURL = req.BaseURL
|
||||||
|
}
|
||||||
|
if req.Model != "" {
|
||||||
|
cfg.Model = req.Model
|
||||||
|
}
|
||||||
|
// 传了新的 key 就用新的(掩码/空则沿用已保存的)
|
||||||
|
if req.APIKey != "" && !containsMask(req.APIKey) {
|
||||||
|
cfg.APIKey = req.APIKey
|
||||||
|
}
|
||||||
|
|
||||||
|
reply, err := services.TestAIConnection(cfg)
|
||||||
|
if err != nil {
|
||||||
|
utils.Error(c, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, gin.H{"reply": reply, "model": cfg.Model})
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsMask(s string) bool {
|
||||||
|
for i := 0; i+2 < len(s); i++ {
|
||||||
|
if s[i] == '*' && s[i+1] == '*' && s[i+2] == '*' {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
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"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||||
|
runs, total := services.ListAIRuns(page, pageSize)
|
||||||
|
utils.Success(c, gin.H{"list": runs, "total": total, "page": page, "pageSize": pageSize})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRun 单条审计详情(含脱敏后的完整 payload)。
|
||||||
|
func (ctrl *AIController) GetRun(c *gin.Context) {
|
||||||
|
id, err := utils.IntParam(c, "id")
|
||||||
|
if err != nil {
|
||||||
|
utils.BadRequest(c, "ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
run := services.GetAIRun(uint(id))
|
||||||
|
if run == nil {
|
||||||
|
utils.Error(c, 404, "记录不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, run)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// AIConfig 大模型接入配置(存在 settings 表里,APIKey 加密保存)。
|
||||||
|
//
|
||||||
|
// 设计取向:只支持 OpenAI 兼容协议 —— DeepSeek / 通义 / 内网 vLLM / Ollama /
|
||||||
|
// 任意 OpenAI 兼容网关都覆盖了,不针对厂商写适配器,保持"通用"。
|
||||||
|
type AIConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Provider string `json:"provider"` // 固定 openai-compatible(保留字段便于将来扩展)
|
||||||
|
BaseURL string `json:"baseUrl"` // 如 https://api.deepseek.com/v1
|
||||||
|
// APIKey 返回给前端时永远是掩码(sk-***abcd);保存时为空表示"不改动"
|
||||||
|
APIKey string `json:"apiKey"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
MaxTokens int `json:"maxTokens"`
|
||||||
|
TimeoutSec int `json:"timeoutSec"`
|
||||||
|
// DailyTokenCap 每用户每日 token 上限(成本闸门);0 = 不限制
|
||||||
|
DailyTokenCap int `json:"dailyTokenCap"`
|
||||||
|
// MaxScanBytes 单次分析允许扫描的日志字节上限(成本闸门);0 = 用默认 2GB
|
||||||
|
MaxScanBytes int64 `json:"maxScanBytes"`
|
||||||
|
// RedactExtra 额外脱敏正则(每行一个),命中的内容替换为 ***
|
||||||
|
RedactExtra []string `json:"redactExtra"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AIAnalysisRun 一次 AI 分析的审计记录。
|
||||||
|
//
|
||||||
|
// 之所以连 PromptPayload 一起存:出问题时能回答"你到底把什么发给了模型",
|
||||||
|
// 这是脱敏与合规的底线。payload 已脱敏,且长度在主流程里截断。
|
||||||
|
type AIAnalysisRun struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
UserID uint `json:"userId"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
SourceID uint `json:"sourceId"`
|
||||||
|
SourceName string `json:"sourceName"`
|
||||||
|
RangeMinutes int `json:"rangeMinutes"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Question string `json:"question"`
|
||||||
|
Status string `json:"status"` // ok | failed | no-model | quota-exceeded
|
||||||
|
Error string `json:"error"`
|
||||||
|
Conclusion string `json:"conclusion"` // 模型返回的结构化 JSON(字符串)
|
||||||
|
PromptPayload string `json:"promptPayload"`
|
||||||
|
PromptTokens int `json:"promptTokens"`
|
||||||
|
CompletionTokens int `json:"completionTokens"`
|
||||||
|
ScanBytes int64 `json:"scanBytes"`
|
||||||
|
DurationMs int64 `json:"durationMs"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
@@ -46,6 +46,8 @@ type JsonDB struct {
|
|||||||
DailyVolumeSnapshots []models.DailyVolumeSnapshot `json:"dailyVolumeSnapshots"`
|
DailyVolumeSnapshots []models.DailyVolumeSnapshot `json:"dailyVolumeSnapshots"`
|
||||||
// AppVolumeSnapshots 按小时留存的 per-app 日志量(长范围排行读这个,不扫日志内容)
|
// AppVolumeSnapshots 按小时留存的 per-app 日志量(长范围排行读这个,不扫日志内容)
|
||||||
AppVolumeSnapshots []models.AppVolumeSnapshot `json:"appVolumeSnapshots"`
|
AppVolumeSnapshots []models.AppVolumeSnapshot `json:"appVolumeSnapshots"`
|
||||||
|
// AIAnalysisRuns AI 分析的审计记录(含脱敏后的 payload,用于合规追溯与每日 token 统计)
|
||||||
|
AIAnalysisRuns []models.AIAnalysisRun `json:"aiAnalysisRuns"`
|
||||||
|
|
||||||
// SchemaVersion 数据结构版本号。
|
// SchemaVersion 数据结构版本号。
|
||||||
// 之前加字段全靠 JSON 反序列化的零值兜底,字段一多必然出事
|
// 之前加字段全靠 JSON 反序列化的零值兜底,字段一多必然出事
|
||||||
@@ -617,6 +619,7 @@ func (d *JsonDB) Reload() error {
|
|||||||
d.AuditLogs = fresh.AuditLogs
|
d.AuditLogs = fresh.AuditLogs
|
||||||
d.DailyVolumeSnapshots = fresh.DailyVolumeSnapshots
|
d.DailyVolumeSnapshots = fresh.DailyVolumeSnapshots
|
||||||
d.AppVolumeSnapshots = fresh.AppVolumeSnapshots
|
d.AppVolumeSnapshots = fresh.AppVolumeSnapshots
|
||||||
|
d.AIAnalysisRuns = fresh.AIAnalysisRuns
|
||||||
d.SchemaVersion = fresh.SchemaVersion
|
d.SchemaVersion = fresh.SchemaVersion
|
||||||
d.sequences = map[string]uint{}
|
d.sequences = map[string]uint{}
|
||||||
for _, u := range d.Users {
|
for _, u := range d.Users {
|
||||||
@@ -694,6 +697,11 @@ func (d *JsonDB) Reload() error {
|
|||||||
d.sequences["appVolumeSnapshots"] = v.ID
|
d.sequences["appVolumeSnapshots"] = v.ID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, v := range d.AIAnalysisRuns {
|
||||||
|
if v.ID > d.sequences["aiAnalysisRuns"] {
|
||||||
|
d.sequences["aiAnalysisRuns"] = v.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -862,6 +870,7 @@ func InitDB() error {
|
|||||||
"auditLogs": 0,
|
"auditLogs": 0,
|
||||||
"dailyVolumeSnapshots": 0,
|
"dailyVolumeSnapshots": 0,
|
||||||
"appVolumeSnapshots": 0,
|
"appVolumeSnapshots": 0,
|
||||||
|
"aiAnalysisRuns": 0,
|
||||||
},
|
},
|
||||||
saveChan: make(chan saveRequest, 10),
|
saveChan: make(chan saveRequest, 10),
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ func SetupRouter() *gin.Engine {
|
|||||||
auditCtrl := controllers.NewAuditController()
|
auditCtrl := controllers.NewAuditController()
|
||||||
systemCtrl := controllers.NewSystemController()
|
systemCtrl := controllers.NewSystemController()
|
||||||
registryCtrl := controllers.NewImageRegistryController()
|
registryCtrl := controllers.NewImageRegistryController()
|
||||||
|
aiCtrl := controllers.NewAIController()
|
||||||
|
|
||||||
// API v1
|
// API v1
|
||||||
api := r.Group("/api/v1")
|
api := r.Group("/api/v1")
|
||||||
@@ -121,6 +122,18 @@ func SetupRouter() *gin.Engine {
|
|||||||
|
|
||||||
// 镜像源(国内加速/海外/私有仓库)
|
// 镜像源(国内加速/海外/私有仓库)
|
||||||
authorized.GET("/settings/image-registry", registryCtrl.Get)
|
authorized.GET("/settings/image-registry", registryCtrl.Get)
|
||||||
|
|
||||||
|
// ── AIOps:AI 日志分析 ──────────────────────────────────────
|
||||||
|
// 模型配置仅管理员可改(涉及密钥与费用);分析本身对所有有数据源权限的用户开放。
|
||||||
|
ai := authorized.Group("/ai")
|
||||||
|
{
|
||||||
|
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", aiCtrl.Analyze)
|
||||||
|
ai.GET("/runs", aiCtrl.ListRuns)
|
||||||
|
ai.GET("/runs/:id", aiCtrl.GetRun)
|
||||||
|
}
|
||||||
authorized.PUT("/settings/image-registry", middleware.AdminAuth(), registryCtrl.Save)
|
authorized.PUT("/settings/image-registry", middleware.AdminAuth(), registryCtrl.Save)
|
||||||
|
|
||||||
// 告警:飞书通知配置 + 告警规则
|
// 告警:飞书通知配置 + 告警规则
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ func TestCriticalRoutes(t *testing.T) {
|
|||||||
// 设置类:保留期必须同时有读和写(写要管理员)
|
// 设置类:保留期必须同时有读和写(写要管理员)
|
||||||
{"GET", "/api/v1/settings/retention"},
|
{"GET", "/api/v1/settings/retention"},
|
||||||
{"PUT", "/api/v1/settings/retention"},
|
{"PUT", "/api/v1/settings/retention"},
|
||||||
|
// AIOps:模型配置(管理员)+ 分析 + 审计历史
|
||||||
|
{"GET", "/api/v1/ai/config"},
|
||||||
|
{"PUT", "/api/v1/ai/config"},
|
||||||
|
{"POST", "/api/v1/ai/config/test"},
|
||||||
|
{"POST", "/api/v1/ai/analyze"},
|
||||||
|
{"GET", "/api/v1/ai/runs"},
|
||||||
|
{"GET", "/api/v1/ai/runs/:id"},
|
||||||
// 备份恢复接口已在上游被移除(commit f87fb4b),这里不再断言
|
// 备份恢复接口已在上游被移除(commit f87fb4b),这里不再断言
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"lokistack/models"
|
||||||
|
"lokistack/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AIAnalysisResult 结构化结论。
|
||||||
|
//
|
||||||
|
// 强制模型输出这个结构而不是自由文本,原因:
|
||||||
|
// 1. 前端能分区渲染(结论/证据/建议/不确定),可读性远好于一段散文;
|
||||||
|
// 2. 每条结论都要挂 evidence,逼模型把话落在数据上,降低编造;
|
||||||
|
// 3. unknowns 单独一栏,明确"不知道什么"比编一个答案有价值得多。
|
||||||
|
type AIAnalysisResult struct {
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Findings []AIFinding `json:"findings"`
|
||||||
|
Actions []string `json:"actions"`
|
||||||
|
Unknowns []string `json:"unknowns"`
|
||||||
|
RawText string `json:"-"`
|
||||||
|
ParseErr string `json:"parseError,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIFinding struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Evidence string `json:"evidence"`
|
||||||
|
Severity string `json:"severity"` // high | medium | low
|
||||||
|
}
|
||||||
|
|
||||||
|
// AIAnalysisOutcome 一次分析的完整返回(含成本信息)。
|
||||||
|
type AIAnalysisOutcome struct {
|
||||||
|
Result AIAnalysisResult `json:"result"`
|
||||||
|
Signals *AISignalBundle `json:"signals"`
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
Run models.AIAnalysisRun `json:"run"`
|
||||||
|
Warnings []string `json:"warnings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const aiSystemPrompt = `你是资深 SRE,负责分析 Loki 日志平台的运行状况。
|
||||||
|
|
||||||
|
严格规则:
|
||||||
|
1. 只能基于我给你的"信号数据"下结论。**不许编造**日志内容、错误信息、时间点或服务名。
|
||||||
|
2. 每一条结论都必须在 evidence 里引用信号数据中的具体数字或条目。
|
||||||
|
3. 如果信号数据不足以判断根因,把它写进 unknowns,不要猜。
|
||||||
|
4. 你看不到日志原文(只有统计与排行),因此不要描述"日志里写了什么"。
|
||||||
|
5. 输出必须是**单个 JSON 对象**,不要 markdown 代码块,不要任何解释性前后缀。
|
||||||
|
|
||||||
|
JSON 结构:
|
||||||
|
{
|
||||||
|
"summary": "两三句话的整体判断",
|
||||||
|
"findings": [{"title": "简短结论", "evidence": "引用具体数据", "severity": "high|medium|low"}],
|
||||||
|
"actions": ["可执行的下一步动作,按优先级排序"],
|
||||||
|
"unknowns": ["无法从当前数据判断的点"]
|
||||||
|
}`
|
||||||
|
|
||||||
|
// RunAIAnalysis 执行一次分析:采集信号 → 脱敏 → 调模型 → 解析 → 落审计。
|
||||||
|
func RunAIAnalysis(ctx context.Context, cfg models.AIConfig, src *models.LogSource, userID uint, username string, admin bool, from, to time.Time, question string) (*AIAnalysisOutcome, error) {
|
||||||
|
if !cfg.Enabled {
|
||||||
|
return nil, fmt.Errorf("AI 分析未启用:请先在「智能运维 → 模型配置」中开启并保存")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 每日配额闸门(成本控制第一层)──
|
||||||
|
if cfg.DailyTokenCap > 0 {
|
||||||
|
used := AITokenUsedToday(userID)
|
||||||
|
if used >= cfg.DailyTokenCap {
|
||||||
|
run := models.AIAnalysisRun{
|
||||||
|
UserID: userID, Username: username, SourceID: src.ID, SourceName: src.Name,
|
||||||
|
Status: "quota-exceeded", Error: fmt.Sprintf("今日 AI token 用量已达上限(%d/%d)", used, cfg.DailyTokenCap),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
persistAIRun(&run)
|
||||||
|
return nil, fmt.Errorf("今日 AI 用量已达上限(%d/%d tokens),可在模型配置中调整上限或明天再试", used, cfg.DailyTokenCap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
started := time.Now()
|
||||||
|
signals, err := CollectAISignals(ctx, src, userID, admin, from, to)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 扫描预算闸门:P0 全是索引/快照,实际扫描为 0,这里留出扩展位
|
||||||
|
if cfg.MaxScanBytes > 0 && signals.TotalBytes > cfg.MaxScanBytes*8 {
|
||||||
|
signals.Warnings = append(signals.Warnings,
|
||||||
|
fmt.Sprintf("该范围日志体积 %.1f GB 偏大,已只使用索引统计(未读取日志内容)",
|
||||||
|
float64(signals.TotalBytes)/1024/1024/1024))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 脱敏(第二层:隐私)──
|
||||||
|
rawPrompt := SignalsAsText(signals)
|
||||||
|
redacted, invalidRules := RedactText(rawPrompt, cfg.RedactExtra)
|
||||||
|
if question = strings.TrimSpace(question); question != "" {
|
||||||
|
redacted += "\n\n运维补充的问题:" + RedactTextOnly(question, cfg.RedactExtra)
|
||||||
|
}
|
||||||
|
warnings := append([]string{}, signals.Warnings...)
|
||||||
|
for _, r := range invalidRules {
|
||||||
|
warnings = append(warnings, "额外脱敏规则无效已跳过:"+r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 调模型 ──
|
||||||
|
reply, promptTokens, completionTokens, callErr := aiChat(ctx, cfg, []aiMessage{
|
||||||
|
{Role: "system", Content: aiSystemPrompt},
|
||||||
|
{Role: "user", Content: redacted},
|
||||||
|
})
|
||||||
|
|
||||||
|
run := models.AIAnalysisRun{
|
||||||
|
UserID: userID, Username: username, SourceID: src.ID, SourceName: src.Name,
|
||||||
|
RangeMinutes: signals.RangeMinutes, Model: cfg.Model, Question: question,
|
||||||
|
PromptPayload: truncateStr(redacted, 20000),
|
||||||
|
PromptTokens: promptTokens, CompletionTokens: completionTokens,
|
||||||
|
ScanBytes: 0, // P0 不扫内容
|
||||||
|
DurationMs: time.Since(started).Milliseconds(),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if callErr != nil {
|
||||||
|
run.Status = "failed"
|
||||||
|
run.Error = callErr.Error()
|
||||||
|
run.DurationMs = time.Since(started).Milliseconds()
|
||||||
|
persistAIRun(&run)
|
||||||
|
return nil, callErr
|
||||||
|
}
|
||||||
|
|
||||||
|
result := parseAIResult(reply)
|
||||||
|
conclusion, _ := json.Marshal(result)
|
||||||
|
run.Status = "ok"
|
||||||
|
run.Conclusion = string(conclusion)
|
||||||
|
run.DurationMs = time.Since(started).Milliseconds()
|
||||||
|
persistAIRun(&run)
|
||||||
|
|
||||||
|
return &AIAnalysisOutcome{
|
||||||
|
Result: result, Signals: signals, Prompt: redacted, Run: run, Warnings: warnings,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedactTextOnly 只脱敏不做规则校验提示(用于短文本如用户提问)。
|
||||||
|
func RedactTextOnly(text string, extra []string) string {
|
||||||
|
out, _ := RedactText(text, extra)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAIResult 宽松解析模型输出。
|
||||||
|
//
|
||||||
|
// 模型常见的"不听话"有三种:包了 ```json 代码块、前后加了解释、
|
||||||
|
// 直接返回纯文本。前两种剥壳后重试,第三种降级为 summary=原文,
|
||||||
|
// 保证用户至少能看到内容,而不是一个"解析失败"。
|
||||||
|
func parseAIResult(reply string) AIAnalysisResult {
|
||||||
|
text := strings.TrimSpace(reply)
|
||||||
|
text = strings.TrimPrefix(text, "```json")
|
||||||
|
text = strings.TrimPrefix(text, "```")
|
||||||
|
text = strings.TrimSuffix(text, "```")
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if idx := strings.Index(text, "{"); idx > 0 {
|
||||||
|
text = text[idx:]
|
||||||
|
}
|
||||||
|
if end := strings.LastIndex(text, "}"); end >= 0 && end < len(text)-1 {
|
||||||
|
text = text[:end+1]
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed AIAnalysisResult
|
||||||
|
if err := json.Unmarshal([]byte(text), &parsed); err == nil && (parsed.Summary != "" || len(parsed.Findings) > 0) {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
return AIAnalysisResult{
|
||||||
|
Summary: strings.TrimSpace(reply),
|
||||||
|
Findings: []AIFinding{},
|
||||||
|
Actions: []string{},
|
||||||
|
Unknowns: []string{},
|
||||||
|
RawText: reply,
|
||||||
|
ParseErr: "模型未按 JSON 格式返回,已按纯文本展示",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistAIRun 落审计(同时用于每日 token 统计)。
|
||||||
|
func persistAIRun(run *models.AIAnalysisRun) {
|
||||||
|
db := repository.GetDB()
|
||||||
|
if db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newID := db.NextID("aiAnalysisRuns")
|
||||||
|
db.Lock()
|
||||||
|
defer db.Unlock()
|
||||||
|
run.ID = newID
|
||||||
|
db.AIAnalysisRuns = append(db.AIAnalysisRuns, *run)
|
||||||
|
// 只保留最近 500 条,避免审计表无限膨胀(明细可通过日志追溯)
|
||||||
|
if len(db.AIAnalysisRuns) > 500 {
|
||||||
|
db.AIAnalysisRuns = db.AIAnalysisRuns[len(db.AIAnalysisRuns)-500:]
|
||||||
|
}
|
||||||
|
db.RequestSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAIRuns 分页查询审计记录(按时间倒序)。
|
||||||
|
func ListAIRuns(page, pageSize int) ([]models.AIAnalysisRun, int) {
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize <= 0 || pageSize > 100 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
db := repository.GetDB()
|
||||||
|
if db == nil {
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
db.RLock()
|
||||||
|
all := make([]models.AIAnalysisRun, len(db.AIAnalysisRuns))
|
||||||
|
copy(all, db.AIAnalysisRuns)
|
||||||
|
db.RUnlock()
|
||||||
|
|
||||||
|
total := len(all)
|
||||||
|
// 倒序(新的在前)
|
||||||
|
for i, j := 0, len(all)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
all[i], all[j] = all[j], all[i]
|
||||||
|
}
|
||||||
|
start := (page - 1) * pageSize
|
||||||
|
if start >= total {
|
||||||
|
return []models.AIAnalysisRun{}, total
|
||||||
|
}
|
||||||
|
end := start + pageSize
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
return all[start:end], total
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAIRun 取单条审计记录(用于查看完整 payload)。
|
||||||
|
func GetAIRun(id uint) *models.AIAnalysisRun {
|
||||||
|
db := repository.GetDB()
|
||||||
|
if db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
db.RLock()
|
||||||
|
defer db.RUnlock()
|
||||||
|
for i := range db.AIAnalysisRuns {
|
||||||
|
if db.AIAnalysisRuns[i].ID == id {
|
||||||
|
run := db.AIAnalysisRuns[i]
|
||||||
|
return &run
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"lokistack/models"
|
||||||
|
"lokistack/pkg/crypto"
|
||||||
|
"lokistack/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
const aiConfigSettingKey = "ai_config"
|
||||||
|
|
||||||
|
// 成本相关的默认值(宁可保守:额度小、扫描少,用户觉得不够再调)
|
||||||
|
const (
|
||||||
|
defaultAIMaxTokens = 1500
|
||||||
|
defaultAITimeoutSec = 120
|
||||||
|
defaultAITemperature = 0.1 // 分析场景要稳定复现,不要发挥
|
||||||
|
defaultAIDailyTokenCap = 500000
|
||||||
|
defaultAIMaxScanBytes = 2 << 30 // 2GiB
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetAIConfig 读配置(返回真实 APIKey,仅供内部调用)。
|
||||||
|
func GetAIConfig() models.AIConfig {
|
||||||
|
cfg := models.AIConfig{
|
||||||
|
Provider: "openai-compatible",
|
||||||
|
Temperature: defaultAITemperature,
|
||||||
|
MaxTokens: defaultAIMaxTokens,
|
||||||
|
TimeoutSec: defaultAITimeoutSec,
|
||||||
|
DailyTokenCap: defaultAIDailyTokenCap,
|
||||||
|
MaxScanBytes: defaultAIMaxScanBytes,
|
||||||
|
}
|
||||||
|
db := repository.GetDB()
|
||||||
|
if db == nil {
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
db.RLock()
|
||||||
|
raw := ""
|
||||||
|
for _, s := range db.Settings {
|
||||||
|
if s.Key == aiConfigSettingKey {
|
||||||
|
raw = s.Value
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.RUnlock()
|
||||||
|
if raw == "" {
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
var stored models.AIConfig
|
||||||
|
if err := json.Unmarshal([]byte(raw), &stored); err != nil {
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
if stored.APIKey != "" {
|
||||||
|
stored.APIKey = crypto.DecryptField(stored.APIKey)
|
||||||
|
}
|
||||||
|
if stored.Provider == "" {
|
||||||
|
stored.Provider = "openai-compatible"
|
||||||
|
}
|
||||||
|
if stored.MaxTokens <= 0 {
|
||||||
|
stored.MaxTokens = defaultAIMaxTokens
|
||||||
|
}
|
||||||
|
if stored.TimeoutSec <= 0 {
|
||||||
|
stored.TimeoutSec = defaultAITimeoutSec
|
||||||
|
}
|
||||||
|
if stored.MaxScanBytes <= 0 {
|
||||||
|
stored.MaxScanBytes = defaultAIMaxScanBytes
|
||||||
|
}
|
||||||
|
return stored
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaskedAIConfig 返回给前端的版本:APIKey 只留头尾。
|
||||||
|
func MaskedAIConfig() models.AIConfig {
|
||||||
|
cfg := GetAIConfig()
|
||||||
|
cfg.APIKey = maskKey(cfg.APIKey)
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func maskKey(key string) string {
|
||||||
|
if key == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(key) <= 8 {
|
||||||
|
return "***"
|
||||||
|
}
|
||||||
|
return key[:3] + "***" + key[len(key)-4:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveAIConfig 保存配置。
|
||||||
|
//
|
||||||
|
// apiKey 传空 = 沿用旧值(前端拿到的是掩码,不能把掩码存回去);
|
||||||
|
// 传 "***" 开头的掩码也视为沿用,避免用户只是改模型名却把 key 清空。
|
||||||
|
func SaveAIConfig(in models.AIConfig, apiKey string) error {
|
||||||
|
old := GetAIConfig()
|
||||||
|
key := strings.TrimSpace(apiKey)
|
||||||
|
switch {
|
||||||
|
case key == "":
|
||||||
|
key = old.APIKey
|
||||||
|
case strings.Contains(key, "***"):
|
||||||
|
key = old.APIKey
|
||||||
|
}
|
||||||
|
if in.Enabled && (strings.TrimSpace(in.BaseURL) == "" || strings.TrimSpace(in.Model) == "") {
|
||||||
|
return fmt.Errorf("启用 AI 分析需要填写 Base URL 与模型名")
|
||||||
|
}
|
||||||
|
if in.Enabled && key == "" {
|
||||||
|
return fmt.Errorf("启用 AI 分析需要填写 API Key(内网无鉴权的服务可填任意占位值)")
|
||||||
|
}
|
||||||
|
if in.BaseURL != "" && !strings.HasPrefix(in.BaseURL, "http") {
|
||||||
|
return fmt.Errorf("Base URL 必须以 http:// 或 https:// 开头(例如 https://api.deepseek.com/v1)")
|
||||||
|
}
|
||||||
|
|
||||||
|
enc, err := crypto.EncryptField(key)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("加密 API Key 失败: %w", err)
|
||||||
|
}
|
||||||
|
saved := models.AIConfig{
|
||||||
|
Enabled: in.Enabled,
|
||||||
|
Provider: "openai-compatible",
|
||||||
|
BaseURL: strings.TrimSuffix(strings.TrimSpace(in.BaseURL), "/"),
|
||||||
|
APIKey: enc,
|
||||||
|
Model: strings.TrimSpace(in.Model),
|
||||||
|
Temperature: in.Temperature,
|
||||||
|
MaxTokens: in.MaxTokens,
|
||||||
|
TimeoutSec: in.TimeoutSec,
|
||||||
|
DailyTokenCap: in.DailyTokenCap,
|
||||||
|
MaxScanBytes: in.MaxScanBytes,
|
||||||
|
RedactExtra: in.RedactExtra,
|
||||||
|
}
|
||||||
|
if saved.MaxTokens <= 0 {
|
||||||
|
saved.MaxTokens = defaultAIMaxTokens
|
||||||
|
}
|
||||||
|
if saved.TimeoutSec <= 0 {
|
||||||
|
saved.TimeoutSec = defaultAITimeoutSec
|
||||||
|
}
|
||||||
|
if saved.MaxScanBytes <= 0 {
|
||||||
|
saved.MaxScanBytes = defaultAIMaxScanBytes
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(saved)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
db := repository.GetDB()
|
||||||
|
if db == nil {
|
||||||
|
return fmt.Errorf("数据库未初始化")
|
||||||
|
}
|
||||||
|
newID := db.NextID("settings")
|
||||||
|
db.Lock()
|
||||||
|
defer db.Unlock()
|
||||||
|
for i := range db.Settings {
|
||||||
|
if db.Settings[i].Key == aiConfigSettingKey {
|
||||||
|
db.Settings[i].Value = string(data)
|
||||||
|
db.RequestSave()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.Settings = append(db.Settings, models.SystemSetting{ID: newID, Key: aiConfigSettingKey, Value: string(data)})
|
||||||
|
db.RequestSave()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateToDay 取当天 00:00(本地时区),用于"今日已用 token"统计。
|
||||||
|
func truncateToDay() time.Time {
|
||||||
|
now := time.Now()
|
||||||
|
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||||
|
}
|
||||||
|
|
||||||
|
// AITokenUsedToday 某用户今日已消耗的 token 总量(每日配额闸门用)。
|
||||||
|
func AITokenUsedToday(userID uint) int {
|
||||||
|
db := repository.GetDB()
|
||||||
|
if db == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
today := truncateToDay()
|
||||||
|
db.RLock()
|
||||||
|
defer db.RUnlock()
|
||||||
|
total := 0
|
||||||
|
for _, r := range db.AIAnalysisRuns {
|
||||||
|
if r.UserID == userID && !r.CreatedAt.Before(today) {
|
||||||
|
total += r.PromptTokens + r.CompletionTokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"lokistack/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OpenAI 兼容 Chat Completions 客户端。
|
||||||
|
//
|
||||||
|
// 只依赖 /chat/completions 这一个端点,所以 DeepSeek、通义、
|
||||||
|
// 内网 vLLM、Ollama(/v1)、各类网关都能直接用,不需要按厂商写适配。
|
||||||
|
|
||||||
|
type aiMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type aiChatRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []aiMessage `json:"messages"`
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
MaxTokens int `json:"max_tokens,omitempty"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type aiChatResponse struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
FinishReason string `json:"finish_reason"`
|
||||||
|
} `json:"choices"`
|
||||||
|
Usage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
} `json:"usage"`
|
||||||
|
Error *struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// aiChat 调一次对话补全。
|
||||||
|
//
|
||||||
|
// 错误信息必须具体:把上游返回的 message 原样带出来,不要包装成
|
||||||
|
// "调用失败" —— 常见原因(key 错、模型名不存在、baseURL 路径少了 /v1、
|
||||||
|
// 内网模型没起)都在上游 message 里,藏起来只会让用户去猜。
|
||||||
|
func aiChat(ctx context.Context, cfg models.AIConfig, messages []aiMessage) (string, int, int, error) {
|
||||||
|
if strings.TrimSpace(cfg.BaseURL) == "" || strings.TrimSpace(cfg.Model) == "" {
|
||||||
|
return "", 0, 0, fmt.Errorf("未配置模型:请先在「智能运维 → 模型配置」填写 Base URL 与模型名")
|
||||||
|
}
|
||||||
|
url := strings.TrimSuffix(cfg.BaseURL, "/") + "/chat/completions"
|
||||||
|
body, err := json.Marshal(aiChatRequest{
|
||||||
|
Model: cfg.Model,
|
||||||
|
Messages: messages,
|
||||||
|
Temperature: cfg.Temperature,
|
||||||
|
MaxTokens: cfg.MaxTokens,
|
||||||
|
Stream: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := time.Duration(cfg.TimeoutSec) * time.Second
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 120 * time.Second
|
||||||
|
}
|
||||||
|
reqCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, 0, fmt.Errorf("构造请求失败: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if cfg.APIKey != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
if reqCtx.Err() == context.DeadlineExceeded {
|
||||||
|
return "", 0, 0, fmt.Errorf("模型响应超时(%d 秒):可调大「超时」或换更快的模型", cfg.TimeoutSec)
|
||||||
|
}
|
||||||
|
return "", 0, 0, fmt.Errorf("连接模型服务失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
var parsed aiChatResponse
|
||||||
|
if json.Unmarshal(raw, &parsed) == nil && parsed.Error != nil && parsed.Error.Message != "" {
|
||||||
|
return "", 0, 0, fmt.Errorf("模型返回 %d:%s", resp.StatusCode, parsed.Error.Message)
|
||||||
|
}
|
||||||
|
return "", 0, 0, fmt.Errorf("模型返回 %d:%s", resp.StatusCode, truncateStr(string(raw), 300))
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed aiChatResponse
|
||||||
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||||
|
return "", 0, 0, fmt.Errorf("解析模型响应失败(可能不是 OpenAI 兼容接口): %s", truncateStr(string(raw), 200))
|
||||||
|
}
|
||||||
|
if len(parsed.Choices) == 0 {
|
||||||
|
return "", 0, 0, fmt.Errorf("模型没有返回任何内容(finish_reason 为空)")
|
||||||
|
}
|
||||||
|
return parsed.Choices[0].Message.Content, parsed.Usage.PromptTokens, parsed.Usage.CompletionTokens, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAIConnection 连通性测试:发一句最小请求,验证 baseURL/key/model 三件套。
|
||||||
|
func TestAIConnection(cfg models.AIConfig) (string, error) {
|
||||||
|
cfg.MaxTokens = 32
|
||||||
|
cfg.TimeoutSec = 30
|
||||||
|
reply, _, _, err := aiChat(context.Background(), cfg, []aiMessage{
|
||||||
|
{Role: "user", Content: "只回复两个字:正常"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(reply), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 脱敏:日志里经常夹带凭据,送给外部模型前必须处理。
|
||||||
|
//
|
||||||
|
// 原则:
|
||||||
|
// - 宁可多脱一点,也不能漏掉凭据;
|
||||||
|
// - 但**不要脱掉排查必需的信息**:内网 IP、端口、主机名、命名空间、
|
||||||
|
// Pod 名、错误码都要保留,否则模型给不出有用结论。
|
||||||
|
// - 每条规则都替换成带类型的占位符(<IP> / <TOKEN>),
|
||||||
|
// 这样模型仍能看出"这里原本是个 token",语义不丢。
|
||||||
|
|
||||||
|
type redactRule struct {
|
||||||
|
pattern *regexp.Regexp
|
||||||
|
replacement string
|
||||||
|
}
|
||||||
|
|
||||||
|
var builtinRedactRules = []redactRule{
|
||||||
|
// Bearer / JWT —— 一定是凭据
|
||||||
|
{regexp.MustCompile(`(?i)bearer\s+[A-Za-z0-9\-._~+/]{12,}=*`), "Bearer <TOKEN>"},
|
||||||
|
{regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}`), "<JWT>"},
|
||||||
|
// key=value 形式的敏感字段
|
||||||
|
{regexp.MustCompile(`(?i)\b(password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|secret[_-]?key)\b\s*[=:]\s*("?[^\s"',;]{4,}"?)`), "$1=<REDACTED>"},
|
||||||
|
// URL 里的 basic auth:scheme://user:pass@host
|
||||||
|
// 密码里可能含 @(如 P@ssw0rd),所以取到**最后一个 @** 之前的所有内容:
|
||||||
|
// 用贪婪 [^\s/]+ 再回溯到最后一个 @,能正确切出 password 与 host。
|
||||||
|
{regexp.MustCompile(`://([^:/\s]+):([^\s/]+)@`), "://$1:<REDACTED>@"},
|
||||||
|
// AK/SK 形式(云厂商常见 16~40 位字母数字混合,带已知前缀)
|
||||||
|
{regexp.MustCompile(`\b(AKIA|LTAI|AKID)[A-Za-z0-9]{12,}\b`), "<CLOUD_AK>"},
|
||||||
|
// 手机号 / 身份证
|
||||||
|
{regexp.MustCompile(`\b1[3-9]\d{9}\b`), "<PHONE>"},
|
||||||
|
{regexp.MustCompile(`\b\d{17}[\dXx]\b`), "<ID_CARD>"},
|
||||||
|
// 邮箱
|
||||||
|
{regexp.MustCompile(`\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b`), "<EMAIL>"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedactText 对文本做脱敏,extra 是用户在设置里加的额外正则。
|
||||||
|
//
|
||||||
|
// 额外正则写错(如括号不闭合)不能让整个分析失败:静默跳过,
|
||||||
|
// 但上层会把这些失效规则一并提示给用户。
|
||||||
|
func RedactText(text string, extra []string) (string, []string) {
|
||||||
|
out := text
|
||||||
|
for _, rule := range builtinRedactRules {
|
||||||
|
out = rule.pattern.ReplaceAllString(out, rule.replacement)
|
||||||
|
}
|
||||||
|
var invalid []string
|
||||||
|
for _, raw := range extra {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
re, err := regexp.Compile(raw)
|
||||||
|
if err != nil {
|
||||||
|
invalid = append(invalid, raw)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = re.ReplaceAllString(out, "<REDACTED>")
|
||||||
|
}
|
||||||
|
return out, invalid
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 脱敏的两条底线:
|
||||||
|
// 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
|
||||||
|
password=Sup3rS3cret! user-admin
|
||||||
|
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefghijklmn
|
||||||
|
url: mysql://appuser:P@ssw0rd@10.2.3.4:3306/cex
|
||||||
|
contact: ops@example.com phone: 13800138000
|
||||||
|
accessKey=AKIAIOSFODNN7EXAMPLE`
|
||||||
|
|
||||||
|
out, invalid := RedactText(in, nil)
|
||||||
|
if len(invalid) != 0 {
|
||||||
|
t.Fatalf("内置规则不应产生无效正则: %v", invalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
mustRedact := []string{"Sup3rS3cret", "eyJhbGciOiJIUzI1NiJ9", "P@ssw0rd", "ops@example.com", "13800138000", "AKIAIOSFODNN7EXAMPLE"}
|
||||||
|
for _, secret := range mustRedact {
|
||||||
|
if strings.Contains(out, secret) {
|
||||||
|
t.Errorf("敏感内容未脱敏: %q\n%s", secret, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mustKeep := []string{"172.19.16.17:6379", "connection refused", "10.2.3.4:3306", "redis"}
|
||||||
|
for _, keep := range mustKeep {
|
||||||
|
if !strings.Contains(out, keep) {
|
||||||
|
t.Errorf("排查必需的信息被误删: %q\n%s", keep, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户自定义正则写错不能拖垮整次分析:跳过并在返回值里报告。
|
||||||
|
func TestRedactTextInvalidExtraRule(t *testing.T) {
|
||||||
|
out, invalid := RedactText("hello internal-id-12345", []string{`(unclosed`, `internal-id-\d+`})
|
||||||
|
if len(invalid) != 1 || invalid[0] != `(unclosed` {
|
||||||
|
t.Errorf("应报告 1 条无效规则,实际 %v", invalid)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "internal-id-12345") {
|
||||||
|
t.Errorf("有效自定义规则应生效: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模型经常不按 JSON 返回,解析必须宽容,不能只报"解析失败"。
|
||||||
|
func TestParseAIResultTolerance(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string // 期望的 summary 片段
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "标准 JSON",
|
||||||
|
in: `{"summary":"日志量正常","findings":[],"actions":[],"unknowns":[]}`,
|
||||||
|
want: "日志量正常",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "包了 ```json 代码块",
|
||||||
|
in: "```json\n{\"summary\":\"体积上涨 2 倍\",\"findings\":[{\"title\":\"t\",\"evidence\":\"e\",\"severity\":\"high\"}],\"actions\":[],\"unknowns\":[]}\n```",
|
||||||
|
want: "体积上涨 2 倍",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "前后加了解释",
|
||||||
|
in: `分析如下:{"summary":"存在告警","findings":[],"actions":[],"unknowns":[]} 希望有帮助`,
|
||||||
|
want: "存在告警",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "纯文本(降级)",
|
||||||
|
in: "看起来一切正常,没有异常日志。",
|
||||||
|
want: "看起来一切正常",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got := parseAIResult(c.in)
|
||||||
|
if !strings.Contains(got.Summary, c.want) {
|
||||||
|
t.Errorf("summary 不符: got %q want contains %q", got.Summary, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"lokistack/models"
|
||||||
|
"lokistack/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AI 分析的"信号包":把 GB 级日志压成 KB 级结构化数据。
|
||||||
|
//
|
||||||
|
// 成本优先原则(P0 的硬约束):
|
||||||
|
// - 体积/趋势 → index/volume_range(实测 24h 仅 54ms,不读日志内容)
|
||||||
|
// - 条数/流数 → index/stats,只允许在 ≤2 小时窗口上用
|
||||||
|
// (全天 index/stats 实测 20 秒,绝不能进 AI 路径)
|
||||||
|
// - 应用排行 → 平台按小时预聚合的快照(毫秒级)
|
||||||
|
// - 告警 → 平台库
|
||||||
|
//
|
||||||
|
// 也就是说:**P0 完全不扫日志内容**,一次分析对 Loki 的压力接近 0。
|
||||||
|
type AISignalBundle struct {
|
||||||
|
SourceID uint `json:"sourceId"`
|
||||||
|
SourceName string `json:"sourceName"`
|
||||||
|
Selector string `json:"selector"`
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
RangeMinutes int `json:"rangeMinutes"`
|
||||||
|
|
||||||
|
TotalBytes int64 `json:"totalBytes"`
|
||||||
|
TotalEntries int64 `json:"totalEntries"`
|
||||||
|
// EntriesExact 条数是否为精确值(false = 范围过大跳过了 index/stats)
|
||||||
|
EntriesExact bool `json:"entriesExact"`
|
||||||
|
|
||||||
|
Streams int64 `json:"streams"`
|
||||||
|
Chunks int64 `json:"chunks"`
|
||||||
|
|
||||||
|
Trend []AITrendPoint `json:"trend"`
|
||||||
|
Apps []AISignalApp `json:"apps"`
|
||||||
|
|
||||||
|
Baseline *AIBaseline `json:"baseline,omitempty"`
|
||||||
|
Alerts []AIAlert `json:"alerts"`
|
||||||
|
|
||||||
|
// SnapshotHours 平台按小时预聚合已留存的小时数(影响排行的可信度)
|
||||||
|
SnapshotHours int `json:"snapshotHours"`
|
||||||
|
// Warnings 采集过程中的降级说明(如"范围过大,条数为估算")
|
||||||
|
Warnings []string `json:"warnings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// aiDisplayZone 结论中展示时间用的时区。
|
||||||
|
//
|
||||||
|
// 为什么不直接用 time.Local:静态编译的容器镜像通常没有 /usr/share/zoneinfo,
|
||||||
|
// TZ 也没设,time.Local 就是 UTC —— 结果是模型在结论里写"凌晨 6 点日志下跌",
|
||||||
|
// 而运维当时是下午 2 点,直接被当成数据错误。
|
||||||
|
// 默认东八区(本地运维环境),需要别的时区可用 LOKISTACK_DISPLAY_TZ=UTC 覆盖。
|
||||||
|
func aiDisplayZone() *time.Location {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(os.Getenv("LOKISTACK_DISPLAY_TZ")), "UTC") {
|
||||||
|
return time.UTC
|
||||||
|
}
|
||||||
|
return time.FixedZone("CST", 8*3600)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AITrendPoint struct {
|
||||||
|
Time string `json:"time"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AISignalApp struct {
|
||||||
|
App string `json:"app"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
Entries int64 `json:"entries"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
Share string `json:"share"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIBaseline struct {
|
||||||
|
// PreviousBytes 上一周期同时长的体积(等长窗口对比)
|
||||||
|
PreviousBytes int64 `json:"previousBytes"`
|
||||||
|
CurrentBytes int64 `json:"currentBytes"`
|
||||||
|
DeltaPercent float64 `json:"deltaPercent"`
|
||||||
|
ComparisonNote string `json:"comparisonNote"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIAlert struct {
|
||||||
|
RuleName string `json:"ruleName"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectAISignals 采集一次分析所需的全部信号。
|
||||||
|
func CollectAISignals(ctx context.Context, src *models.LogSource, userID uint, admin bool, from, to time.Time) (*AISignalBundle, error) {
|
||||||
|
if src == nil {
|
||||||
|
return nil, fmt.Errorf("数据源不存在")
|
||||||
|
}
|
||||||
|
if !to.After(from) {
|
||||||
|
return nil, fmt.Errorf("时间范围无效")
|
||||||
|
}
|
||||||
|
// 复用查询授权:AI 能看到的数据范围必须与当前用户一致,不多不少
|
||||||
|
selector, err := AuthorizedLogQuery(src, userID, admin, `{namespace=~".+"}`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle := &AISignalBundle{
|
||||||
|
SourceID: src.ID,
|
||||||
|
SourceName: src.Name,
|
||||||
|
Selector: selector,
|
||||||
|
// 时间统一按本地时区展示:Loki 返回的是 UTC,
|
||||||
|
// 直接给模型 UTC 会让结论里出现"凌晨 6 点"这种和用户作息对不上的时间。
|
||||||
|
From: from.In(aiDisplayZone()).Format("2006-01-02 15:04"),
|
||||||
|
To: to.In(aiDisplayZone()).Format("2006-01-02 15:04"),
|
||||||
|
RangeMinutes: int(to.Sub(from).Minutes()),
|
||||||
|
Alerts: []AIAlert{},
|
||||||
|
Trend: []AITrendPoint{},
|
||||||
|
Apps: []AISignalApp{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 体积 + 趋势:volume_range(只读索引元数据)──
|
||||||
|
buckets, err := LokiHistogram(ctx, src, selector, from.UnixNano(), to.UnixNano())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, b := range buckets {
|
||||||
|
var bytes int64
|
||||||
|
switch v := b["count"].(type) {
|
||||||
|
case int64:
|
||||||
|
bytes = v
|
||||||
|
case float64:
|
||||||
|
bytes = int64(v)
|
||||||
|
}
|
||||||
|
ts, _ := b["timestamp"].(string)
|
||||||
|
if parsed, perr := time.Parse(time.RFC3339, ts); perr == nil {
|
||||||
|
ts = parsed.In(aiDisplayZone()).Format("15:04")
|
||||||
|
}
|
||||||
|
bundle.TotalBytes += bytes
|
||||||
|
bundle.Trend = append(bundle.Trend, AITrendPoint{Time: ts, Bytes: bytes})
|
||||||
|
}
|
||||||
|
// 趋势只保留尾部若干桶,避免 prompt 过长
|
||||||
|
if len(bundle.Trend) > 24 {
|
||||||
|
bundle.Trend = bundle.Trend[len(bundle.Trend)-24:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 条数:仅短窗口走 index/stats(长窗口太慢,直接跳过并标注)──
|
||||||
|
if bundle.RangeMinutes <= 120 {
|
||||||
|
if st, statErr := LokiStatsCached(ctx, src, selector, from.UnixNano(), to.UnixNano()); statErr == nil {
|
||||||
|
bundle.TotalEntries = st.Entries
|
||||||
|
bundle.Streams = st.Streams
|
||||||
|
bundle.Chunks = st.Chunks
|
||||||
|
bundle.EntriesExact = true
|
||||||
|
} else {
|
||||||
|
bundle.Warnings = append(bundle.Warnings, "条数统计失败:"+truncateStr(statErr.Error(), 120))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bundle.Warnings = append(bundle.Warnings, "时间范围超过 2 小时,条数为估算(跳过 index/stats:全天统计实测需 20 秒)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 应用排行:平台按小时预聚合快照 ──
|
||||||
|
if rows := GetAppRanking(src.ID, from, to, 10, "entries"); len(rows) > 0 {
|
||||||
|
var total int64
|
||||||
|
for _, r := range rows {
|
||||||
|
total += r.Entries
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
share := ""
|
||||||
|
if total > 0 {
|
||||||
|
share = fmt.Sprintf("%.1f%%", float64(r.Entries)/float64(total)*100)
|
||||||
|
}
|
||||||
|
bundle.Apps = append(bundle.Apps, AISignalApp{
|
||||||
|
App: r.App, Namespace: r.Namespace, Entries: r.Entries, Bytes: r.Bytes, Share: share,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hours, _ := AppVolumeCoverageInfo(src.ID)
|
||||||
|
bundle.SnapshotHours = hours
|
||||||
|
if len(bundle.Apps) == 0 {
|
||||||
|
bundle.Warnings = append(bundle.Warnings, "应用排行暂无数据:平台按小时预聚合还在积累(当前留存 0 小时)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 基线对比:与上一个等长窗口比 ──
|
||||||
|
prevFrom := from.Add(-to.Sub(from))
|
||||||
|
prevTo := from
|
||||||
|
if prevBuckets, perr := LokiHistogram(ctx, src, selector, prevFrom.UnixNano(), prevTo.UnixNano()); perr == nil {
|
||||||
|
var prevBytes int64
|
||||||
|
for _, b := range prevBuckets {
|
||||||
|
switch v := b["count"].(type) {
|
||||||
|
case int64:
|
||||||
|
prevBytes += v
|
||||||
|
case float64:
|
||||||
|
prevBytes += int64(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if prevBytes > 0 || bundle.TotalBytes > 0 {
|
||||||
|
delta := 0.0
|
||||||
|
if prevBytes > 0 {
|
||||||
|
delta = (float64(bundle.TotalBytes) - float64(prevBytes)) / float64(prevBytes) * 100
|
||||||
|
}
|
||||||
|
bundle.Baseline = &AIBaseline{
|
||||||
|
PreviousBytes: prevBytes,
|
||||||
|
CurrentBytes: bundle.TotalBytes,
|
||||||
|
DeltaPercent: delta,
|
||||||
|
ComparisonNote: fmt.Sprintf("与上一个 %d 分钟等长窗口对比", bundle.RangeMinutes),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 告警:窗口内的投递记录 ──
|
||||||
|
db := repository.GetDB()
|
||||||
|
if db != nil {
|
||||||
|
db.RLock()
|
||||||
|
for _, d := range db.AlertDeliveries {
|
||||||
|
if d.CreatedAt.Before(from) || d.CreatedAt.After(to) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bundle.Alerts = append(bundle.Alerts, AIAlert{
|
||||||
|
RuleName: d.RuleName, Status: d.Status, Count: d.Count,
|
||||||
|
CreatedAt: d.CreatedAt.In(aiDisplayZone()).Format("2006-01-02 15:04"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
db.RUnlock()
|
||||||
|
}
|
||||||
|
sort.Slice(bundle.Alerts, func(i, j int) bool { return bundle.Alerts[i].CreatedAt > bundle.Alerts[j].CreatedAt })
|
||||||
|
if len(bundle.Alerts) > 10 {
|
||||||
|
bundle.Alerts = bundle.Alerts[:10]
|
||||||
|
}
|
||||||
|
return bundle, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignalsAsText 把信号包渲染成给模型看的紧凑文本(不是 JSON,省 token)。
|
||||||
|
func SignalsAsText(b *AISignalBundle) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
fmt.Fprintf(&sb, "数据源:%s\n", b.SourceName)
|
||||||
|
fmt.Fprintf(&sb, "时间范围:%s ~ %s(%d 分钟)\n", b.From, b.To, b.RangeMinutes)
|
||||||
|
fmt.Fprintf(&sb, "日志体积:%.2f GB\n", float64(b.TotalBytes)/1024/1024/1024)
|
||||||
|
if b.EntriesExact {
|
||||||
|
fmt.Fprintf(&sb, "日志条数:%d\n", b.TotalEntries)
|
||||||
|
} else {
|
||||||
|
sb.WriteString("日志条数:(范围过大未精确统计)\n")
|
||||||
|
}
|
||||||
|
if b.Streams > 0 || b.Chunks > 0 {
|
||||||
|
fmt.Fprintf(&sb, "流数/块数:%d / %d\n", b.Streams, b.Chunks)
|
||||||
|
}
|
||||||
|
if b.Baseline != nil {
|
||||||
|
fmt.Fprintf(&sb, "基线对比:上一等长窗口 %.2f GB → 当前 %.2f GB,变化 %+.1f%%(%s)\n",
|
||||||
|
float64(b.Baseline.PreviousBytes)/1024/1024/1024,
|
||||||
|
float64(b.Baseline.CurrentBytes)/1024/1024/1024,
|
||||||
|
b.Baseline.DeltaPercent, b.Baseline.ComparisonNote)
|
||||||
|
}
|
||||||
|
if len(b.Apps) > 0 {
|
||||||
|
sb.WriteString("\n应用日志量排行(来自平台小时级预聚合):\n")
|
||||||
|
for i, a := range b.Apps {
|
||||||
|
fmt.Fprintf(&sb, " %d. %s/%s 条数=%d 占比=%s\n", i+1, a.Namespace, a.App, a.Entries, a.Share)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(b.Trend) > 0 {
|
||||||
|
sb.WriteString("\n体积趋势(最近分桶):\n")
|
||||||
|
for _, t := range b.Trend {
|
||||||
|
fmt.Fprintf(&sb, " %s %.1f MB\n", t.Time, float64(t.Bytes)/1024/1024)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(b.Alerts) > 0 {
|
||||||
|
sb.WriteString("\n窗口内告警:\n")
|
||||||
|
for _, a := range b.Alerts {
|
||||||
|
fmt.Fprintf(&sb, " %s %s 命中=%d 状态=%s\n", a.CreatedAt, a.RuleName, a.Count, a.Status)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sb.WriteString("\n窗口内告警:无\n")
|
||||||
|
}
|
||||||
|
if len(b.Warnings) > 0 {
|
||||||
|
sb.WriteString("\n数据限制说明:\n")
|
||||||
|
for _, w := range b.Warnings {
|
||||||
|
fmt.Fprintf(&sb, " - %s\n", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
@@ -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}`),
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ import zhAlerts from './locales/zh/alerts'
|
|||||||
import enAlerts from './locales/en/alerts'
|
import enAlerts from './locales/en/alerts'
|
||||||
import zhSettings from './locales/zh/settings'
|
import zhSettings from './locales/zh/settings'
|
||||||
import enSettings from './locales/en/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'
|
const LANG_KEY = 'lokistack.i18n.lang'
|
||||||
export type AppLanguage = 'zh' | 'en'
|
export type AppLanguage = 'zh' | 'en'
|
||||||
@@ -62,6 +64,7 @@ void i18n.use(initReactI18next).init({
|
|||||||
users: zhUsers,
|
users: zhUsers,
|
||||||
alerts: zhAlerts,
|
alerts: zhAlerts,
|
||||||
settings: zhSettings,
|
settings: zhSettings,
|
||||||
|
aiops: zhAiops,
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
common: enCommon,
|
common: enCommon,
|
||||||
@@ -76,6 +79,7 @@ void i18n.use(initReactI18next).init({
|
|||||||
users: enUsers,
|
users: enUsers,
|
||||||
alerts: enAlerts,
|
alerts: enAlerts,
|
||||||
settings: enSettings,
|
settings: enSettings,
|
||||||
|
aiops: enAiops,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
lng: detectLanguage(),
|
lng: detectLanguage(),
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}
|
||||||
@@ -16,7 +16,8 @@ export default {
|
|||||||
collectors: 'Log Collectors',
|
collectors: 'Log Collectors',
|
||||||
logs: 'Log Query',
|
logs: 'Log Query',
|
||||||
alerts: 'Alerts',
|
alerts: 'Alerts',
|
||||||
analysis: 'Log Analysis',
|
analysis: 'Log analysis',
|
||||||
|
aiops: 'AIOps',
|
||||||
settings: 'General',
|
settings: 'General',
|
||||||
ldap: 'LDAP',
|
ldap: 'LDAP',
|
||||||
registry: 'Image Registry',
|
registry: 'Image Registry',
|
||||||
|
|||||||
@@ -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: '暂无数据',
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export default {
|
|||||||
logs: '日志查询',
|
logs: '日志查询',
|
||||||
alerts: '日志告警',
|
alerts: '日志告警',
|
||||||
analysis: '日志分析',
|
analysis: '日志分析',
|
||||||
|
aiops: '智能运维',
|
||||||
settings: '通用配置',
|
settings: '通用配置',
|
||||||
ldap: 'LDAP 接入',
|
ldap: 'LDAP 接入',
|
||||||
registry: '默认镜像源',
|
registry: '默认镜像源',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
ScrollText,
|
ScrollText,
|
||||||
BellRing,
|
BellRing,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
Sparkles,
|
||||||
History,
|
History,
|
||||||
Boxes,
|
Boxes,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
@@ -38,6 +39,7 @@ const LogsPage = lazy(() => import('@/pages/logs'));
|
|||||||
const AlertsPage = lazy(() => import('@/pages/alerts'));
|
const AlertsPage = lazy(() => import('@/pages/alerts'));
|
||||||
const UsersPage = lazy(() => import('@/pages/users'));
|
const UsersPage = lazy(() => import('@/pages/users'));
|
||||||
const AnalysisPage = lazy(() => import('@/pages/analysis'));
|
const AnalysisPage = lazy(() => import('@/pages/analysis'));
|
||||||
|
const AiopsPage = lazy(() => import('@/pages/aiops'));
|
||||||
const AuditPage = lazy(() => import('@/pages/audit'));
|
const AuditPage = lazy(() => import('@/pages/audit'));
|
||||||
|
|
||||||
export const routes: AppRoute[] = [
|
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: '/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 } },
|
{ 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 } },
|
||||||
|
|
||||||
// ── 告警:独立入口(要盯的,不能被折叠) ──
|
// ── 告警:独立入口(要盯的,不能被折叠) ──
|
||||||
// 告警仅管理员:普通用户按「查询授权」只看指定的日志(菜单只剩日志查询)。
|
// 告警仅管理员:普通用户按「查询授权」只看指定的日志(菜单只剩日志查询)。
|
||||||
|
|||||||
Reference in new issue
Block a user