feat: Agent CLI 与终端审计回放全面优化
- Agent CLI: 多轮对话、SSH 长连接、流式推理、Evolver 按用户隔离、历史侧栏 - 终端审计: 回放播放器、命令书签、筛选分页导出、命令输出统计、录制落盘修复 - 修复 session_commands 表迁移、Kill 竞态导致录制丢失、无录制会话回放提示 - 审计历史页只读(移除删除),移除回放弹框录制搜索
This commit is contained in:
1 parent
9a3dd304f7
commit
1acb0c49af
81 files changed
+3916
-1655
No files matched your search
+171
-37
@@ -54,9 +54,10 @@ type wsEvent struct {
|
||||
|
||||
// wsControl 前端→后端控制消息 // Logan.Li
|
||||
type wsControl struct {
|
||||
Type string `json:"type"` // start/confirm/deny/abort
|
||||
Query string `json:"query,omitempty"` // start 时任务描述
|
||||
ConfirmID string `json:"confirm_id,omitempty"` // confirm/deny 时
|
||||
Type string `json:"type"` // start/continue/confirm/deny/abort
|
||||
Query string `json:"query,omitempty"` // start 时任务描述
|
||||
SafetyMode string `json:"safety_mode,omitempty"` // strict/balanced/permissive
|
||||
ConfirmID string `json:"confirm_id,omitempty"` // confirm/deny 时
|
||||
}
|
||||
|
||||
// writeWSCloseError 在 agentConn 建立前,直接通过 conn 推送一条 error 事件再关闭连接 // Logan.Li
|
||||
@@ -73,15 +74,21 @@ func writeWSCloseError(conn *websocket.Conn, msg string) {
|
||||
|
||||
// agentConn 单个 WS 连接的运行态 // Logan.Li
|
||||
type agentConn struct {
|
||||
conn *websocket.Conn
|
||||
db *gorm.DB
|
||||
userID uint
|
||||
server model.Server
|
||||
session *model.AgentCLISession
|
||||
cancel context.CancelFunc
|
||||
writeMu sync.Mutex
|
||||
confirms map[string]chan bool
|
||||
cfMu sync.Mutex
|
||||
conn *websocket.Conn
|
||||
db *gorm.DB
|
||||
userID uint
|
||||
server model.Server
|
||||
session *model.AgentCLISession
|
||||
taskCtx context.Context
|
||||
cancel context.CancelFunc
|
||||
totalTokens int
|
||||
tokenMu sync.Mutex
|
||||
writeMu sync.Mutex
|
||||
confirms map[string]chan bool
|
||||
cfMu sync.Mutex
|
||||
convHistory []llm.Message // 多轮追问对话历史(不含 system) // Logan.Li
|
||||
running bool
|
||||
runningMu sync.Mutex
|
||||
}
|
||||
|
||||
// Run WebSocket 入口:GET /ws/agent-cli?id=<serverID>&token=<jwt> // Logan.Li
|
||||
@@ -121,6 +128,7 @@ func (h *AgentCLIHandler) Run(c *gin.Context) {
|
||||
ac := &agentConn{
|
||||
conn: conn, db: h.db,
|
||||
userID: middleware.GetUserID(c), server: server,
|
||||
taskCtx: context.Background(),
|
||||
confirms: make(map[string]chan bool),
|
||||
}
|
||||
// 推送主机上下文
|
||||
@@ -144,7 +152,13 @@ func (h *AgentCLIHandler) Run(c *gin.Context) {
|
||||
ac.write(wsEvent{Type: "error", Message: "任务描述不能为空"})
|
||||
continue
|
||||
}
|
||||
ac.startTask(sshCfg, llmCfg, ctrl.Query)
|
||||
ac.startTask(sshCfg, ctrl.Query, ctrl.SafetyMode)
|
||||
case "continue":
|
||||
if ctrl.Query == "" {
|
||||
ac.write(wsEvent{Type: "error", Message: "追问内容不能为空"})
|
||||
continue
|
||||
}
|
||||
ac.continueTask(sshCfg, ctrl.Query, ctrl.SafetyMode)
|
||||
case "confirm":
|
||||
ac.resolveConfirm(ctrl.ConfirmID, true)
|
||||
case "deny":
|
||||
@@ -155,11 +169,28 @@ func (h *AgentCLIHandler) Run(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// startTask 启动一次任务(新会话 + 新引擎) // Logan.Li
|
||||
func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, llmCfg model.LLMConfig, query string) {
|
||||
ac.abort() // 取消上一次任务
|
||||
// parseSafetyMode 解析前端传入的安全模式 // Logan.Li
|
||||
func parseSafetyMode(mode string) safety.Mode {
|
||||
switch mode {
|
||||
case "strict":
|
||||
return safety.ModeStrict
|
||||
case "permissive":
|
||||
return safety.ModePermissive
|
||||
default:
|
||||
return safety.ModeBalanced
|
||||
}
|
||||
}
|
||||
|
||||
// startTask 启动新任务(新会话,清空对话历史) // Logan.Li
|
||||
func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, query, safetyMode string) {
|
||||
ac.abort()
|
||||
|
||||
var llmCfg model.LLMConfig
|
||||
if err := ac.db.First(&llmCfg).Error; err != nil || !llmCfg.Enabled {
|
||||
ac.write(wsEvent{Type: "error", Message: "LLM 服务未配置或未启用,请先在「大模型设置」中配置"})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建 DB 会话
|
||||
title := query
|
||||
if r := []rune(title); len(r) > 60 {
|
||||
title = string(r[:60]) + "..."
|
||||
@@ -175,38 +206,96 @@ func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, llmCfg model.LLMConf
|
||||
return
|
||||
}
|
||||
ac.session = sess
|
||||
ac.convHistory = nil
|
||||
ac.setTotalTokens(0)
|
||||
ac.runTask(sshCfg, sess, query, safetyMode, llmCfg, false)
|
||||
}
|
||||
|
||||
// 存用户消息
|
||||
ac.persistMsg(sess.ID, 0, "user", "user", query, "", "", "", "", false, "", 0)
|
||||
// continueTask 在同一会话内追问(保留上下文) // Logan.Li
|
||||
func (ac *agentConn) continueTask(sshCfg remoteexec.SSHConfig, query, safetyMode string) {
|
||||
if ac.isRunning() {
|
||||
ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"})
|
||||
return
|
||||
}
|
||||
if ac.session == nil {
|
||||
ac.write(wsEvent{Type: "error", Message: "请先开始新任务"})
|
||||
return
|
||||
}
|
||||
if ac.session.Status == "running" {
|
||||
ac.write(wsEvent{Type: "error", Message: "当前会话仍在执行中"})
|
||||
return
|
||||
}
|
||||
|
||||
var llmCfg model.LLMConfig
|
||||
if err := ac.db.First(&llmCfg).Error; err != nil || !llmCfg.Enabled {
|
||||
ac.write(wsEvent{Type: "error", Message: "LLM 服务未配置或未启用,请先在「大模型设置」中配置"})
|
||||
return
|
||||
}
|
||||
ac.runTask(sshCfg, ac.session, query, safetyMode, llmCfg, true)
|
||||
}
|
||||
|
||||
// runTask 执行一轮 ReAct(新建或追问共用) // Logan.Li
|
||||
func (ac *agentConn) runTask(sshCfg remoteexec.SSHConfig, sess *model.AgentCLISession, query, safetyMode string, llmCfg model.LLMConfig, isContinue bool) {
|
||||
if ac.isRunning() {
|
||||
ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"})
|
||||
return
|
||||
}
|
||||
|
||||
prior := ac.convHistory
|
||||
priorTokens := ac.getTotalTokens()
|
||||
roundOffset := 0
|
||||
if !isContinue {
|
||||
prior = nil
|
||||
priorTokens = 0
|
||||
} else {
|
||||
roundOffset = ac.lastRound()
|
||||
}
|
||||
|
||||
coreCfg := core.DefaultConfig()
|
||||
coreCfg.RoundOffset = roundOffset
|
||||
|
||||
// 构建引擎
|
||||
engine, err := agentcli.NewEngine(agentcli.EngineConfig{
|
||||
LLM: llm.Config{
|
||||
Protocol: resolveProtocol(llmCfg.Provider),
|
||||
Endpoint: resolveEndpoint(llmCfg.Provider, llmCfg.Endpoint),
|
||||
APIKey: llmCfg.APIKey, Model: llmCfg.Model,
|
||||
Temperature: llmCfg.Temperature, MaxTokens: llmCfg.MaxTokens,
|
||||
},
|
||||
LLM: buildLLMConfigFromModel(llmCfg),
|
||||
SSH: sshCfg,
|
||||
SafetyMode: safety.ModeBalanced,
|
||||
SafetyMode: parseSafetyMode(safetyMode),
|
||||
MemoryDir: fmt.Sprintf("data/agentcli/user_%d", ac.userID),
|
||||
Core: coreCfg,
|
||||
})
|
||||
if err != nil {
|
||||
ac.finish(sess, "error", "引擎初始化失败: "+err.Error(), 0, time.Duration(0))
|
||||
ac.finish(sess, "error", "引擎初始化失败: "+err.Error(), ac.lastRound(), 0)
|
||||
return
|
||||
}
|
||||
engine.SetCallback(ac.onEvent)
|
||||
engine.SetConfirmFn(ac.confirmFn)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ac.taskCtx = ctx
|
||||
ac.cancel = cancel
|
||||
serverInfo := fmt.Sprintf("主机名: %s | IP: %s:%d | 用户: %s | OS: %s", ac.server.Hostname, ac.server.IP, ac.server.Port, ac.server.Username, ac.server.OS)
|
||||
ac.setRunning(true)
|
||||
ac.db.Model(sess).Update("status", "running")
|
||||
|
||||
ac.write(wsEvent{Type: "session", SessionID: sess.ID, Message: "任务已开始"})
|
||||
ac.persistMsg(sess.ID, 0, "user", "user", query, "", "", "", "", false, "", 0)
|
||||
|
||||
serverInfo := fmt.Sprintf("主机名: %s | IP: %s:%d | 用户: %s | OS: %s",
|
||||
ac.server.Hostname, ac.server.IP, ac.server.Port, ac.server.Username, ac.server.OS)
|
||||
|
||||
msg := "任务已开始"
|
||||
if isContinue {
|
||||
msg = "继续追问"
|
||||
}
|
||||
ac.write(wsEvent{Type: "session", SessionID: sess.ID, Message: msg})
|
||||
|
||||
go func() {
|
||||
defer engine.Close()
|
||||
defer func() {
|
||||
ac.setRunning(false)
|
||||
engine.Close()
|
||||
}()
|
||||
start := time.Now()
|
||||
_, err := engine.Run(ctx, query, serverInfo)
|
||||
result, err := engine.Run(ctx, query, serverInfo, prior, priorTokens)
|
||||
if result != nil && len(result.History) > 0 {
|
||||
ac.convHistory = result.History
|
||||
ac.setTotalTokens(result.TotalTokens)
|
||||
}
|
||||
dur := time.Since(start)
|
||||
if ctx.Err() != nil {
|
||||
ac.finish(sess, "aborted", "任务已中断", ac.lastRound(), dur)
|
||||
@@ -218,8 +307,37 @@ func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, llmCfg model.LLMConf
|
||||
}()
|
||||
}
|
||||
|
||||
func (ac *agentConn) isRunning() bool {
|
||||
ac.runningMu.Lock()
|
||||
defer ac.runningMu.Unlock()
|
||||
return ac.running
|
||||
}
|
||||
|
||||
func (ac *agentConn) setRunning(v bool) {
|
||||
ac.runningMu.Lock()
|
||||
ac.running = v
|
||||
ac.runningMu.Unlock()
|
||||
}
|
||||
|
||||
// setTotalTokens 更新当前任务 token 计数 // Logan.Li
|
||||
func (ac *agentConn) setTotalTokens(n int) {
|
||||
ac.tokenMu.Lock()
|
||||
ac.totalTokens = n
|
||||
ac.tokenMu.Unlock()
|
||||
}
|
||||
|
||||
func (ac *agentConn) getTotalTokens() int {
|
||||
ac.tokenMu.Lock()
|
||||
defer ac.tokenMu.Unlock()
|
||||
return ac.totalTokens
|
||||
}
|
||||
|
||||
// onEvent 引擎事件回调:推 WS + 持久化 // Logan.Li
|
||||
func (ac *agentConn) onEvent(e core.Event) {
|
||||
if e.Type == core.EventProgress && e.Tokens > 0 {
|
||||
ac.setTotalTokens(e.Tokens)
|
||||
}
|
||||
|
||||
ev := wsEvent{
|
||||
Type: string(e.Type), Round: e.Round, Thought: e.Thought,
|
||||
ToolName: e.ToolName, ToolArgs: e.ToolArgs, Risk: e.Risk,
|
||||
@@ -231,7 +349,7 @@ func (ac *agentConn) onEvent(e core.Event) {
|
||||
}
|
||||
ac.write(ev)
|
||||
|
||||
// 持久化(progress 太频繁不入库)
|
||||
// 持久化(progress / thought_delta 太频繁不入库) // Logan.Li
|
||||
if ac.session == nil {
|
||||
return
|
||||
}
|
||||
@@ -245,6 +363,7 @@ func (ac *agentConn) onEvent(e core.Event) {
|
||||
case core.EventToolResult:
|
||||
ac.persistMsg(sid, e.Round, "tool_result", "tool", e.Output, e.ToolName, "", "", "", e.Success, e.Duration, 0)
|
||||
case core.EventDone:
|
||||
ac.setTotalTokens(e.Tokens)
|
||||
ac.persistMsg(sid, e.Round, "done", "assistant", e.Answer, "", "", "", "", true, "", e.Tokens)
|
||||
case core.EventError:
|
||||
ac.persistMsg(sid, e.Round, "error", "assistant", e.Message, "", "", "", "", false, "", 0)
|
||||
@@ -265,6 +384,9 @@ func (ac *agentConn) confirmFn(toolName string, args map[string]interface{}, ris
|
||||
select {
|
||||
case approved := <-ch:
|
||||
return approved, nil
|
||||
case <-ac.taskCtx.Done():
|
||||
ac.delConfirm(confirmID)
|
||||
return false, ac.taskCtx.Err()
|
||||
case <-time.After(5 * time.Minute):
|
||||
ac.delConfirm(confirmID)
|
||||
return false, fmt.Errorf("确认超时")
|
||||
@@ -290,12 +412,21 @@ func (ac *agentConn) delConfirm(id string) {
|
||||
ac.cfMu.Unlock()
|
||||
}
|
||||
|
||||
// abort 中断当前任务 // Logan.Li
|
||||
// abort 中断当前任务并唤醒所有等待中的确认 // Logan.Li
|
||||
func (ac *agentConn) abort() {
|
||||
if ac.cancel != nil {
|
||||
ac.cancel()
|
||||
ac.cancel = nil
|
||||
}
|
||||
ac.cfMu.Lock()
|
||||
for id, ch := range ac.confirms {
|
||||
select {
|
||||
case ch <- false:
|
||||
default:
|
||||
}
|
||||
delete(ac.confirms, id)
|
||||
}
|
||||
ac.cfMu.Unlock()
|
||||
}
|
||||
|
||||
// lastRound 估算已完成轮次(从 DB 取该会话最大 round) // Logan.Li
|
||||
@@ -309,13 +440,16 @@ func (ac *agentConn) lastRound() int {
|
||||
return maxRound
|
||||
}
|
||||
|
||||
// finish 结束会话并更新 DB // Logan.Li
|
||||
// finish 结束本轮执行并更新会话 DB // Logan.Li
|
||||
func (ac *agentConn) finish(sess *model.AgentCLISession, status, errMsg string, rounds int, dur time.Duration) {
|
||||
ac.setRunning(false)
|
||||
tokens := ac.getTotalTokens()
|
||||
if status == "done" || status == "aborted" || status == "error" {
|
||||
ac.write(wsEvent{Type: status, SessionID: sess.ID, Message: errMsg, Round: rounds})
|
||||
ac.write(wsEvent{Type: status, SessionID: sess.ID, Message: errMsg, Round: rounds, Tokens: tokens})
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"status": status, "rounds": rounds, "duration": dur.Milliseconds(),
|
||||
"total_tokens": tokens,
|
||||
}
|
||||
if errMsg != "" {
|
||||
updates["error"] = errMsg
|
||||
@@ -342,4 +476,4 @@ func (ac *agentConn) persistMsg(sid uint, round int, typ, role, content, toolNam
|
||||
// 持久化失败不阻断主流程
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,9 +43,10 @@ func (h *AgentCLIHandler) ListSessions(c *gin.Context) {
|
||||
// GetSession 会话详情(含完整推理消息,供回放) // Logan.Li
|
||||
// GET /api/v1/aiops/agent-cli/sessions/:id
|
||||
func (h *AgentCLIHandler) GetSession(c *gin.Context) {
|
||||
uid := middleware.GetUserID(c)
|
||||
id := c.Param("id")
|
||||
var sess model.AgentCLISession
|
||||
if err := h.db.First(&sess, id).Error; err != nil {
|
||||
if err := h.db.Where("user_id = ?", uid).First(&sess, id).Error; err != nil {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 404, Message: "会话不存在"})
|
||||
return
|
||||
}
|
||||
@@ -57,7 +58,13 @@ func (h *AgentCLIHandler) GetSession(c *gin.Context) {
|
||||
// DeleteSession 删除会话及其消息 // Logan.Li
|
||||
// DELETE /api/v1/aiops/agent-cli/sessions/:id
|
||||
func (h *AgentCLIHandler) DeleteSession(c *gin.Context) {
|
||||
uid := middleware.GetUserID(c)
|
||||
id := c.Param("id")
|
||||
var sess model.AgentCLISession
|
||||
if err := h.db.Where("user_id = ?", uid).First(&sess, id).Error; err != nil {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 404, Message: "会话不存在"})
|
||||
return
|
||||
}
|
||||
if err := h.db.Where("session_id = ?", id).Delete(&model.AgentCLIMessage{}).Error; err != nil {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 500, Message: "删除消息失败: " + err.Error()})
|
||||
return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
@@ -25,6 +26,56 @@ func NewAgentInstallHandler(db *gorm.DB) *AgentInstallHandler {
|
||||
return &AgentInstallHandler{db: db}
|
||||
}
|
||||
|
||||
// auditCtx 快照请求上下文(用户身份 + 来源 IP),用于在异步 goroutine 中安全记录审计 // Logan.Li
|
||||
// gin.Context 在请求返回后会被 gin 池复用,goroutine 内禁止直接访问 c,必须提前快照
|
||||
type auditCtx struct {
|
||||
userID uint
|
||||
username string
|
||||
ip string
|
||||
userAgent string
|
||||
}
|
||||
|
||||
// newAuditCtx 从 gin.Context 提取审计所需上下文快照 // Logan.Li
|
||||
func newAuditCtx(c *gin.Context) auditCtx {
|
||||
uid, _ := c.Get("userID")
|
||||
uname, _ := c.Get("username")
|
||||
id, _ := uid.(uint)
|
||||
name, _ := uname.(string)
|
||||
return auditCtx{
|
||||
userID: id,
|
||||
username: name,
|
||||
ip: c.ClientIP(),
|
||||
userAgent: c.Request.UserAgent(),
|
||||
}
|
||||
}
|
||||
|
||||
// truncateAuditText 截断过长输出,避免审计 Error 字段膨胀 // Logan.Li
|
||||
func truncateAuditText(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) > n {
|
||||
return s[:n] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// recordAgentAudit 记录 Agent 安装/卸载/升级操作审计 // Logan.Li
|
||||
// status: 1=成功, 0=失败;errMsg 记录失败原因(SSH 错误、脚本输出等),用于追溯"安装过程遇到什么问题"
|
||||
func (h *AgentInstallHandler) recordAgentAudit(ac auditCtx, action, resource, resourceID string, status int, errMsg string) {
|
||||
uid := ac.userID
|
||||
h.db.Create(&model.AuditLog{
|
||||
UserID: &uid,
|
||||
Username: ac.username,
|
||||
IP: ac.ip,
|
||||
UserAgent: ac.userAgent,
|
||||
Module: "agent",
|
||||
Action: action,
|
||||
Resource: resource,
|
||||
ResourceID: resourceID,
|
||||
Status: status,
|
||||
Error: errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
// sshDial 复用 webshell.go 的 SSH 连接逻辑(TOFU HostKey 验证)
|
||||
func sshDial(server model.Server) (*ssh.Client, error) {
|
||||
config := &ssh.ClientConfig{
|
||||
@@ -54,16 +105,77 @@ func sshDial(server model.Server) (*ssh.Client, error) {
|
||||
return ssh.Dial("tcp", addr, config)
|
||||
}
|
||||
|
||||
// runSSHCommand 在远程主机执行命令并返回输出
|
||||
// runSSHCommand 在远程主机执行命令并返回输出(默认 60s 超时,防止 sudo 密码等待导致挂死)// Logan.Li
|
||||
func runSSHCommand(client *ssh.Client, cmd string) (string, error) {
|
||||
return runSSHCommandWithTimeout(client, cmd, 60*time.Second)
|
||||
}
|
||||
|
||||
// runSSHCommandWithTimeout 支持自定义超时
|
||||
func runSSHCommandWithTimeout(client *ssh.Client, cmd string, timeout time.Duration) (string, error) {
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
output, err := session.CombinedOutput(cmd)
|
||||
return string(output), err
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
type result struct {
|
||||
output []byte
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
out, err := session.CombinedOutput(cmd)
|
||||
done <- result{output: out, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
session.Signal(ssh.SIGTERM)
|
||||
return "", fmt.Errorf("命令执行超时(%s): %s", timeout, cmd)
|
||||
case r := <-done:
|
||||
return string(r.output), r.err
|
||||
}
|
||||
}
|
||||
|
||||
// installServerURL 根据请求构造后端可访问地址,兼容 Nginx 等反向代理 // Logan.Li
|
||||
func installServerURL(c *gin.Context) string {
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" {
|
||||
scheme = proto
|
||||
}
|
||||
// 优先使用 X-Forwarded-Host(Nginx 暴露给客户端的真实域名),其次用 Host // Logan.Li
|
||||
host := c.Request.Host
|
||||
if fwd := c.GetHeader("X-Forwarded-Host"); fwd != "" {
|
||||
host = fwd
|
||||
}
|
||||
// 去掉 scheme 的默认端口,避免生成 http://example.com:80 这类无效地址 // Logan.Li
|
||||
if scheme == "http" && strings.HasSuffix(host, ":80") {
|
||||
host = strings.TrimSuffix(host, ":80")
|
||||
}
|
||||
if scheme == "https" && strings.HasSuffix(host, ":443") {
|
||||
host = strings.TrimSuffix(host, ":443")
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", scheme, host)
|
||||
}
|
||||
|
||||
// detectAgentVersion SSH 到目标主机读取已安装 Agent 的真实版本号 // Logan.Li
|
||||
func detectAgentVersion(client *ssh.Client) string {
|
||||
verOut, _ := runSSHCommand(client, "/usr/local/bin/aiops-agent version 2>/dev/null || /usr/local/bin/aiops-agent -v 2>/dev/null || echo ''")
|
||||
verOut = strings.TrimSpace(verOut)
|
||||
// 输出形如: AIOps Agent v2.1.0 (built ...) go=go1.24...
|
||||
if strings.HasPrefix(verOut, "AIOps Agent ") {
|
||||
fields := strings.Fields(verOut)
|
||||
if len(fields) >= 3 {
|
||||
return fields[2]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// InstallAgent 远程安装 Agent 到指定服务器
|
||||
@@ -98,6 +210,11 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
|
||||
// 允许安装,不强制 running 状态
|
||||
}
|
||||
|
||||
// 审计上下文 + 资源描述(同步流程,提前快照)// Logan.Li
|
||||
ac := newAuditCtx(c)
|
||||
resource := server.Hostname + " (" + server.IP + ")"
|
||||
resourceID := strconv.Itoa(int(server.ID))
|
||||
|
||||
// 如果请求中提供了凭据,使用请求凭据覆盖服务器存储的凭据进行 SSH
|
||||
effectiveServer := server
|
||||
if req.Username != "" {
|
||||
@@ -118,6 +235,7 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
|
||||
client, err := sshDial(effectiveServer)
|
||||
if err != nil {
|
||||
log.Printf("[AgentInstall] SSH连接失败 %s: %v", server.IP, err)
|
||||
h.recordAgentAudit(ac, "install", resource, resourceID, 0, "SSH连接失败: "+formatSSHError(err))
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 500,
|
||||
Message: "SSH 连接失败: " + formatSSHError(err),
|
||||
@@ -153,21 +271,35 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 3. 构造安装命令(使用 Agent API 服务地址)
|
||||
// 从请求头获取后端可访问地址
|
||||
serverURL := fmt.Sprintf("http://%s", c.Request.Host)
|
||||
token := c.Query("token")
|
||||
serverURL := installServerURL(c)
|
||||
token := req.Token
|
||||
if token == "" {
|
||||
token = c.Query("token")
|
||||
}
|
||||
|
||||
// 4. 构造安装命令:root 用户直接 bash,非 root 用 sudo -n(无密码才执行,避免挂死)// Logan.Li
|
||||
installCmd := fmt.Sprintf(
|
||||
`curl -fsSL %s/agent/install.sh | sudo bash -s -- --server %s --token %s`,
|
||||
`if [ "$(id -u)" -eq 0 ]; then curl -fsSL %s/agent/install.sh | bash -s -- --server %s --token %s; else curl -fsSL %s/agent/install.sh | sudo -n bash -s -- --server %s --token %s; fi`,
|
||||
serverURL, serverURL, token,
|
||||
serverURL, serverURL, token,
|
||||
)
|
||||
|
||||
// 4. 执行安装
|
||||
// 5. 执行安装
|
||||
installOut, err := runSSHCommand(client, installCmd)
|
||||
if err != nil {
|
||||
msg := "安装命令执行失败"
|
||||
lowerOut := strings.ToLower(installOut)
|
||||
if strings.Contains(lowerOut, "sudo: a password is required") || strings.Contains(lowerOut, "需要密码") {
|
||||
msg = "目标主机 SSH 用户没有免密 sudo 权限,请使用 root 账号或配置 passwordless sudo"
|
||||
} else if strings.Contains(lowerOut, "curl") && (strings.Contains(lowerOut, "not found") || strings.Contains(lowerOut, "未找到")) {
|
||||
msg = "目标主机未安装 curl"
|
||||
} else if strings.Contains(lowerOut, "下载失败") || strings.Contains(lowerOut, "failed to connect") || strings.Contains(lowerOut, "connection refused") {
|
||||
msg = "目标主机无法下载安装脚本,请检查后端地址 " + serverURL + " 是否可达"
|
||||
}
|
||||
h.recordAgentAudit(ac, "install", resource, resourceID, 0, msg+": "+truncateAuditText(installOut, 300))
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 500,
|
||||
Message: "安装命令执行失败(可能需要 sudo 权限或 curl 未安装)",
|
||||
Message: msg,
|
||||
Data: installOut,
|
||||
})
|
||||
return
|
||||
@@ -178,12 +310,22 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
|
||||
installed := contains(verifyOut, "active") || contains(verifyOut, "running")
|
||||
|
||||
if installed {
|
||||
// 更新服务器 Agent 状态(版本号:安装成功后由 Agent 下一次上报自动更新)
|
||||
h.db.Model(&server).Updates(map[string]interface{}{
|
||||
// 只更新在线状态;版本号仅在能读到真实版本时才写入,未上报时不写假版本(等 Agent 首次上报真实数据)// Logan.Li
|
||||
updates := map[string]interface{}{
|
||||
"agent_status": "online",
|
||||
"agent_version": "v0.1.0",
|
||||
"last_check_at": time.Now(),
|
||||
})
|
||||
}
|
||||
if agentVersion := detectAgentVersion(client); agentVersion != "" {
|
||||
updates["agent_version"] = agentVersion
|
||||
}
|
||||
h.db.Model(&server).Updates(updates)
|
||||
}
|
||||
|
||||
// 审计:成功或"命令已执行但服务未运行"均记录,便于追溯 // Logan.Li
|
||||
if installed {
|
||||
h.recordAgentAudit(ac, "install", resource, resourceID, 1, "")
|
||||
} else {
|
||||
h.recordAgentAudit(ac, "install", resource, resourceID, 0, "安装命令已执行但服务未检测到运行: "+truncateAuditText(verifyOut, 300))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
@@ -214,7 +356,8 @@ func (h *AgentInstallHandler) installOne(server model.Server, serverURL, token s
|
||||
// 不强制 server.Status==running:只要 SSH 可达即可安装(DB 状态可能滞后于实际开机状态) // Logan.Li
|
||||
|
||||
installCmd := fmt.Sprintf(
|
||||
`curl -fsSL %s/agent/install.sh | sudo bash -s -- --server %s --token %s`,
|
||||
`if [ "$(id -u)" -eq 0 ]; then curl -fsSL %s/agent/install.sh | bash -s -- --server %s --token %s; else curl -fsSL %s/agent/install.sh | sudo -n bash -s -- --server %s --token %s; fi`,
|
||||
serverURL, serverURL, token,
|
||||
serverURL, serverURL, token,
|
||||
)
|
||||
|
||||
@@ -227,11 +370,15 @@ func (h *AgentInstallHandler) installOne(server model.Server, serverURL, token s
|
||||
installed := contains(verifyOut, "active") || contains(verifyOut, "running")
|
||||
|
||||
if installed {
|
||||
h.db.Model(&server).Updates(map[string]interface{}{
|
||||
// 只更新在线状态;版本号仅在能读到真实版本时才写入,未上报时不写假版本 // Logan.Li
|
||||
updates := map[string]interface{}{
|
||||
"agent_status": "online",
|
||||
"agent_version": "v0.1.0",
|
||||
"last_check_at": time.Now(),
|
||||
})
|
||||
}
|
||||
if agentVersion := detectAgentVersion(client); agentVersion != "" {
|
||||
updates["agent_version"] = agentVersion
|
||||
}
|
||||
h.db.Model(&server).Updates(updates)
|
||||
}
|
||||
|
||||
return installed, installOut, verifyOut, nil
|
||||
@@ -239,17 +386,25 @@ func (h *AgentInstallHandler) installOne(server model.Server, serverURL, token s
|
||||
|
||||
// BatchInstallAgent 批量安装 Agent(异步执行,立即返回)// Logan.Li
|
||||
// POST /servers/install-agent
|
||||
// 支持传入临时凭据:key 为服务器 ID,首次连接成功后自动保存到服务器记录
|
||||
func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) {
|
||||
var req struct {
|
||||
ServerIDs []uint `json:"server_ids"`
|
||||
ServerIDs []uint `json:"server_ids"`
|
||||
Credentials map[string]struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
SSHKey string `json:"ssh_key"`
|
||||
AuthType string `json:"auth_type"`
|
||||
} `json:"credentials"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || len(req.ServerIDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请提供要安装的服务器 ID 列表"})
|
||||
return
|
||||
}
|
||||
|
||||
serverURL := fmt.Sprintf("http://%s", c.Request.Host)
|
||||
serverURL := installServerURL(c)
|
||||
token := c.Query("token")
|
||||
ac := newAuditCtx(c) // 提前快照上下文,goroutine 内不可再用 c // Logan.Li
|
||||
|
||||
// 异步执行:立即返回,后台 goroutine 逐台安装
|
||||
go func() {
|
||||
@@ -260,15 +415,39 @@ func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) {
|
||||
log.Printf("[BatchInstall] 服务器 %d 不存在,跳过", sid)
|
||||
continue
|
||||
}
|
||||
installed, _, _, err := h.installOne(server, serverURL, token)
|
||||
|
||||
// 如果请求提供了该服务器的临时凭据,覆盖使用(不立即写入 DB,由 installOne 首次连接成功后保存) // Logan.Li
|
||||
effectiveServer := server
|
||||
if cred, ok := req.Credentials[fmt.Sprintf("%d", sid)]; ok {
|
||||
if cred.Username != "" {
|
||||
effectiveServer.Username = cred.Username
|
||||
}
|
||||
if cred.Password != "" {
|
||||
effectiveServer.Password = cred.Password
|
||||
}
|
||||
if cred.SSHKey != "" {
|
||||
effectiveServer.SSHKey = cred.SSHKey
|
||||
effectiveServer.AuthType = "key"
|
||||
}
|
||||
if cred.AuthType != "" {
|
||||
effectiveServer.AuthType = cred.AuthType
|
||||
}
|
||||
}
|
||||
|
||||
installed, _, verifyOut, err := h.installOne(effectiveServer, serverURL, token)
|
||||
res := server.Hostname + " (" + server.IP + ")"
|
||||
rid := strconv.Itoa(int(server.ID))
|
||||
if err != nil {
|
||||
log.Printf("[BatchInstall] %s (%s) 安装失败: %v", server.Hostname, server.IP, err)
|
||||
h.recordAgentAudit(ac, "install", res, rid, 0, truncateAuditText(err.Error(), 500))
|
||||
} else {
|
||||
successCount++
|
||||
if installed {
|
||||
log.Printf("[BatchInstall] %s Agent 安装成功", server.Hostname)
|
||||
h.recordAgentAudit(ac, "install", res, rid, 1, "")
|
||||
} else {
|
||||
log.Printf("[BatchInstall] %s Agent 安装命令已执行但服务未检测到运行", server.Hostname)
|
||||
h.recordAgentAudit(ac, "install", res, rid, 0, "服务未检测到运行: "+truncateAuditText(verifyOut, 300))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,17 +489,23 @@ func (h *AgentInstallHandler) UninstallAgent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
ac := newAuditCtx(c) // Logan.Li
|
||||
resource := server.Hostname + " (" + server.IP + ")"
|
||||
resourceID := strconv.Itoa(int(server.ID))
|
||||
|
||||
// 无 SSH 凭据的主机(自动注册的)直接改本地状态;注意 SSH key 认证的主机 Password 为空但 SSHKey 非空,仍可远程卸载 // Logan.Li
|
||||
if server.Username == "" || (server.Password == "" && server.SSHKey == "") {
|
||||
h.db.Model(&server).Updates(map[string]interface{}{
|
||||
"agent_status": "offline", "agent_version": "",
|
||||
})
|
||||
h.recordAgentAudit(ac, "uninstall", resource, resourceID, 1, "无SSH凭据,已标记离线")
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已标记为离线(该主机无SSH凭据,无法远程卸载,请手动执行 systemctl stop aiops-agent)"})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := sshDial(server)
|
||||
if err != nil {
|
||||
h.recordAgentAudit(ac, "uninstall", resource, resourceID, 0, "SSH连接失败: "+formatSSHError(err))
|
||||
c.JSON(http.StatusOK, model.Response{Code: 500, Message: "SSH连接失败: " + formatSSHError(err)})
|
||||
return
|
||||
}
|
||||
@@ -331,6 +516,7 @@ func (h *AgentInstallHandler) UninstallAgent(c *gin.Context) {
|
||||
h.db.Model(&server).Updates(map[string]interface{}{
|
||||
"agent_status": "offline", "agent_version": "",
|
||||
})
|
||||
h.recordAgentAudit(ac, "uninstall", resource, resourceID, 1, "")
|
||||
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: fmt.Sprintf("已卸载 %s (%s) 的 Agent", server.Hostname, server.IP)})
|
||||
}
|
||||
@@ -346,6 +532,8 @@ func (h *AgentInstallHandler) BatchUninstallAgent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
ac := newAuditCtx(c) // 提前快照上下文,goroutine 内不可再用 c // Logan.Li
|
||||
|
||||
// 异步执行:立即返回,后台逐台卸载
|
||||
go func() {
|
||||
successCount := 0
|
||||
@@ -355,21 +543,26 @@ func (h *AgentInstallHandler) BatchUninstallAgent(c *gin.Context) {
|
||||
log.Printf("[BatchUninstall] 服务器 %d 不存在,跳过", id)
|
||||
continue
|
||||
}
|
||||
res := server.Hostname + " (" + server.IP + ")"
|
||||
rid := strconv.Itoa(int(server.ID))
|
||||
if server.Username == "" || (server.Password == "" && server.SSHKey == "") {
|
||||
h.db.Model(&server).Updates(map[string]interface{}{"agent_status": "offline", "agent_version": ""})
|
||||
log.Printf("[BatchUninstall] %s 无SSH凭据,已标记离线", server.Hostname)
|
||||
h.recordAgentAudit(ac, "uninstall", res, rid, 1, "无SSH凭据,已标记离线")
|
||||
successCount++
|
||||
continue
|
||||
}
|
||||
client, err := sshDial(server)
|
||||
if err != nil {
|
||||
log.Printf("[BatchUninstall] %s SSH失败: %v", server.Hostname, err)
|
||||
h.recordAgentAudit(ac, "uninstall", res, rid, 0, "SSH连接失败: "+formatSSHError(err))
|
||||
continue
|
||||
}
|
||||
runSSHCommand(client, "systemctl stop aiops-agent 2>/dev/null; systemctl disable aiops-agent 2>/dev/null; rm -f /usr/local/bin/aiops-agent /etc/systemd/system/aiops-agent.service; systemctl daemon-reload 2>/dev/null")
|
||||
client.Close()
|
||||
h.db.Model(&server).Updates(map[string]interface{}{"agent_status": "offline", "agent_version": ""})
|
||||
log.Printf("[BatchUninstall] %s Agent 已卸载", server.Hostname)
|
||||
h.recordAgentAudit(ac, "uninstall", res, rid, 1, "")
|
||||
successCount++
|
||||
}
|
||||
log.Printf("[BatchUninstall] 批量卸载完成: %d/%d 成功", successCount, len(req.ServerIDs))
|
||||
@@ -393,7 +586,8 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
serverURL := fmt.Sprintf("http://%s", c.Request.Host)
|
||||
serverURL := installServerURL(c)
|
||||
ac := newAuditCtx(c) // 提前快照上下文,goroutine 内不可再用 c // Logan.Li
|
||||
|
||||
// 异步执行:立即返回,后台逐台升级
|
||||
go func() {
|
||||
@@ -409,10 +603,14 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
res := server.Hostname + " (" + server.IP + ")"
|
||||
rid := strconv.Itoa(int(server.ID))
|
||||
|
||||
// 检测架构
|
||||
client, err := sshDial(server)
|
||||
if err != nil {
|
||||
log.Printf("[BatchUpgrade] %s SSH连接失败: %v", server.Hostname, err)
|
||||
h.recordAgentAudit(ac, "upgrade", res, rid, 0, "SSH连接失败: "+formatSSHError(err))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -424,7 +622,7 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) {
|
||||
|
||||
// 下载新二进制 + 获取版本号 + 重启服务
|
||||
upgradeScript := fmt.Sprintf(
|
||||
`curl -fsSL -o /tmp/aiops-agent %s/api/agent/bin/%s && chmod +x /tmp/aiops-agent && systemctl stop aiops-agent 2>/dev/null; mv /tmp/aiops-agent /usr/local/bin/aiops-agent && systemctl start aiops-agent; ver=$(/usr/local/bin/aiops-agent output 2>/dev/null | grep 'agent.version' | awk -F'[|[|]' '{print $2}' | tr -d ' '); echo "UPGRADE_OK ver=$ver"`,
|
||||
`curl -fsSL -o /tmp/aiops-agent %s/api/agent/bin/%s && chmod +x /tmp/aiops-agent && systemctl stop aiops-agent 2>/dev/null; mv /tmp/aiops-agent /usr/local/bin/aiops-agent && systemctl start aiops-agent; ver=$(/usr/local/bin/aiops-agent version 2>/dev/null | awk '/AIOps Agent/{print $3}'); echo "UPGRADE_OK ver=$ver"`,
|
||||
serverURL, binaryFile,
|
||||
)
|
||||
|
||||
@@ -433,6 +631,7 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) {
|
||||
|
||||
if err != nil || !contains(out, "UPGRADE_OK") {
|
||||
log.Printf("[BatchUpgrade] %s 升级失败: output=%s err=%v", server.Hostname, out, err)
|
||||
h.recordAgentAudit(ac, "upgrade", res, rid, 0, "升级失败: "+truncateAuditText(out, 300))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -448,6 +647,7 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) {
|
||||
"last_check_at": time.Now(),
|
||||
})
|
||||
log.Printf("[BatchUpgrade] %s Agent 升级成功", server.Hostname)
|
||||
h.recordAgentAudit(ac, "upgrade", res, rid, 1, "")
|
||||
successCount++
|
||||
}
|
||||
log.Printf("[BatchUpgrade] 批量升级完成: %d/%d 成功", successCount, len(req.ServerIDs))
|
||||
|
||||
@@ -145,6 +145,7 @@ func (h *AuditHandler) GetAuditModules(c *gin.Context) {
|
||||
{"value": "navigation", "label": "站内导航"},
|
||||
{"value": "cloud_account", "label": "云账号管理"},
|
||||
{"value": "domain", "label": "域名管理"},
|
||||
{"value": "agent", "label": "Agent管理"},
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: modules}) // Logan.Li
|
||||
|
||||
@@ -73,6 +73,21 @@ func (h *DashboardHandler) GetStats(c *gin.Context) {
|
||||
serverStats.AvgMemory = math.Round(avgMemory*100) / 100
|
||||
serverStats.AvgDisk = math.Round(avgDisk*100) / 100
|
||||
|
||||
// 总资源概览:按 asset_servers 的 cpu/memory/disk 字段汇总(memory 单位为 MB,需转 GB)// Logan.Li
|
||||
var totalCPU, totalMemoryMB, totalDiskGB int64
|
||||
h.db.Model(&model.Server{}).Select("COALESCE(SUM(cpu),0)").Scan(&totalCPU)
|
||||
h.db.Model(&model.Server{}).Select("COALESCE(SUM(memory),0)").Scan(&totalMemoryMB)
|
||||
h.db.Model(&model.Server{}).Select("COALESCE(SUM(disk),0)").Scan(&totalDiskGB)
|
||||
serverStats.TotalCPU = totalCPU
|
||||
serverStats.TotalMemory = totalMemoryMB / 1024 // MB -> GB
|
||||
serverStats.TotalDisk = totalDiskGB
|
||||
if serverStats.TotalMemory > 0 && serverStats.AvgMemory > 0 {
|
||||
serverStats.UsedMemory = int64(math.Round(float64(serverStats.TotalMemory) * serverStats.AvgMemory / 100))
|
||||
}
|
||||
if serverStats.TotalDisk > 0 && serverStats.AvgDisk > 0 {
|
||||
serverStats.UsedDisk = int64(math.Round(float64(serverStats.TotalDisk) * serverStats.AvgDisk / 100))
|
||||
}
|
||||
|
||||
stats.ServerStats = serverStats
|
||||
|
||||
// K8s集群统计
|
||||
|
||||
+24
-21
@@ -55,21 +55,22 @@ func (h *LLMHandler) GetLLMConfig(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
|
||||
"provider": "", "model": "", "api_key": "",
|
||||
"endpoint": "", "temperature": 0.7, "max_tokens": 2000,
|
||||
"system_prompt": "", "enabled": false, "configured": false,
|
||||
"system_prompt": "", "enabled": false, "reasoning_enabled": false, "configured": false,
|
||||
}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
|
||||
"id": cfg.ID,
|
||||
"provider": cfg.Provider,
|
||||
"model": cfg.Model,
|
||||
"api_key": cfg.APIKey,
|
||||
"endpoint": cfg.Endpoint,
|
||||
"temperature": cfg.Temperature,
|
||||
"max_tokens": cfg.MaxTokens,
|
||||
"system_prompt": cfg.SystemPrompt,
|
||||
"enabled": cfg.Enabled,
|
||||
"configured": true,
|
||||
"id": cfg.ID,
|
||||
"provider": cfg.Provider,
|
||||
"model": cfg.Model,
|
||||
"api_key": cfg.APIKey,
|
||||
"endpoint": cfg.Endpoint,
|
||||
"temperature": cfg.Temperature,
|
||||
"max_tokens": cfg.MaxTokens,
|
||||
"system_prompt": cfg.SystemPrompt,
|
||||
"enabled": cfg.Enabled,
|
||||
"reasoning_enabled": cfg.ReasoningEnabled,
|
||||
"configured": true,
|
||||
}})
|
||||
}
|
||||
|
||||
@@ -95,6 +96,7 @@ func (h *LLMHandler) SaveLLMConfig(c *gin.Context) {
|
||||
cfg.MaxTokens = req.MaxTokens
|
||||
cfg.SystemPrompt = req.SystemPrompt
|
||||
cfg.Enabled = req.Enabled
|
||||
cfg.ReasoningEnabled = req.ReasoningEnabled
|
||||
if cfg.ID == 0 {
|
||||
if err := h.db.Create(&cfg).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "保存失败: " + err.Error()})
|
||||
@@ -109,16 +111,17 @@ func (h *LLMHandler) SaveLLMConfig(c *gin.Context) {
|
||||
// 返回脱敏后的配置
|
||||
cfg.APIKey = maskAPIKey(cfg.APIKey)
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "保存成功", Data: gin.H{
|
||||
"id": cfg.ID,
|
||||
"provider": cfg.Provider,
|
||||
"model": cfg.Model,
|
||||
"api_key": cfg.APIKey,
|
||||
"endpoint": cfg.Endpoint,
|
||||
"temperature": cfg.Temperature,
|
||||
"max_tokens": cfg.MaxTokens,
|
||||
"system_prompt": cfg.SystemPrompt,
|
||||
"enabled": cfg.Enabled,
|
||||
"configured": true,
|
||||
"id": cfg.ID,
|
||||
"provider": cfg.Provider,
|
||||
"model": cfg.Model,
|
||||
"api_key": cfg.APIKey,
|
||||
"endpoint": cfg.Endpoint,
|
||||
"temperature": cfg.Temperature,
|
||||
"max_tokens": cfg.MaxTokens,
|
||||
"system_prompt": cfg.SystemPrompt,
|
||||
"enabled": cfg.Enabled,
|
||||
"reasoning_enabled": cfg.ReasoningEnabled,
|
||||
"configured": true,
|
||||
}})
|
||||
}
|
||||
|
||||
|
||||
@@ -14,18 +14,6 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// buildLLMClient 从数据库配置构造 LLM 客户端(按 provider 协议自动路由 OpenAI/Anthropic) // Logan.Li
|
||||
func (h *LLMHandler) buildLLMClient(cfg model.LLMConfig) llm.Client {
|
||||
return llm.NewClient(llm.Config{
|
||||
Protocol: resolveProtocol(cfg.Provider),
|
||||
Endpoint: resolveEndpoint(cfg.Provider, cfg.Endpoint),
|
||||
APIKey: cfg.APIKey,
|
||||
Model: cfg.Model,
|
||||
Temperature: cfg.Temperature,
|
||||
MaxTokens: cfg.MaxTokens,
|
||||
})
|
||||
}
|
||||
|
||||
// toLLMMessages model.ChatMessage[] → llm.Message[](智能助手纯对话,仅填 Role/Content) // Logan.Li
|
||||
func toLLMMessages(msgs []model.ChatMessage) []llm.Message {
|
||||
out := make([]llm.Message, len(msgs))
|
||||
@@ -57,7 +45,7 @@ func (h *LLMHandler) Chat(c *gin.Context) {
|
||||
}
|
||||
messages := append([]model.ChatMessage{{Role: "system", Content: systemPrompt}}, req.Messages...)
|
||||
|
||||
resp, err := h.buildLLMClient(cfg).Complete(c.Request.Context(), &llm.CompletionRequest{
|
||||
resp, err := buildLLMClientFromConfig(cfg).Complete(c.Request.Context(), &llm.CompletionRequest{
|
||||
Messages: toLLMMessages(messages),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -127,7 +115,7 @@ func (h *LLMHandler) ChatStream(c *gin.Context) {
|
||||
|
||||
// 流式补全:逐 token 回调转发 SSE;ctx 取消(客户端断开)由 llm.Client 内部终止上游读取
|
||||
var contentBuilder strings.Builder
|
||||
_, streamErr := h.buildLLMClient(cfg).Stream(c.Request.Context(), &llm.CompletionRequest{
|
||||
_, streamErr := buildLLMClientFromConfig(cfg).Stream(c.Request.Context(), &llm.CompletionRequest{
|
||||
Messages: toLLMMessages(messages),
|
||||
}, func(delta string) {
|
||||
contentBuilder.WriteString(delta)
|
||||
@@ -188,7 +176,7 @@ func writeSSEError(c *gin.Context, code int, message string) {
|
||||
c.Writer.Flush()
|
||||
}
|
||||
|
||||
// TestLLMConnection 测试 LLM 连接(发送一条 ping 消息) // Logan.Li
|
||||
// TestLLMConnection 测试 LLM 连接(询问模型身份并返回回答) // Logan.Li
|
||||
// POST /api/v1/aiops/llm-config/test
|
||||
func (h *LLMHandler) TestLLMConnection(c *gin.Context) {
|
||||
var cfg model.LLMConfig
|
||||
@@ -196,12 +184,22 @@ func (h *LLMHandler) TestLLMConnection(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 400, Message: "请先保存配置"})
|
||||
return
|
||||
}
|
||||
_, err := h.buildLLMClient(cfg).Complete(c.Request.Context(), &llm.CompletionRequest{
|
||||
Messages: []llm.Message{{Role: "user", Content: "ping"}},
|
||||
if !cfg.Enabled {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 400, Message: "请先启用 LLM 服务"})
|
||||
return
|
||||
}
|
||||
resp, err := buildLLMClientFromConfig(cfg).Complete(c.Request.Context(), &llm.CompletionRequest{
|
||||
Messages: []llm.Message{{Role: "user", Content: "你是什么模型?请简短回答。"}},
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 502, Message: "连接失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "连接成功"})
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "连接成功", Data: gin.H{
|
||||
"answer": resp.Message.Content,
|
||||
"model": resp.Model,
|
||||
"tokens_used": resp.Usage.TotalTokens,
|
||||
"provider": cfg.Provider,
|
||||
"reasoning_enabled": cfg.ReasoningEnabled,
|
||||
}})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// llm_client.go - LLM 客户端构造公共函数 // Logan.Li
|
||||
// Agent CLI、智能助手等模块共用,避免字段遗漏(如 ReasoningEnabled)。
|
||||
package controller
|
||||
|
||||
import (
|
||||
"ezops/model"
|
||||
"ezops/pkg/agentcli/llm"
|
||||
)
|
||||
|
||||
// buildLLMConfigFromModel 从 model.LLMConfig 构造引擎用 llm.Config // Logan.Li
|
||||
func buildLLMConfigFromModel(cfg model.LLMConfig) llm.Config {
|
||||
return llm.Config{
|
||||
Protocol: resolveProtocol(cfg.Provider),
|
||||
Endpoint: resolveEndpoint(cfg.Provider, cfg.Endpoint),
|
||||
APIKey: cfg.APIKey,
|
||||
Model: cfg.Model,
|
||||
Temperature: cfg.Temperature,
|
||||
MaxTokens: cfg.MaxTokens,
|
||||
ReasoningEnabled: cfg.ReasoningEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
// buildLLMClientFromConfig 从 model.LLMConfig 构造 LLM 客户端(OpenAI/Anthropic 自动路由) // Logan.Li
|
||||
func buildLLMClientFromConfig(cfg model.LLMConfig) llm.Client {
|
||||
return llm.NewClient(buildLLMConfigFromModel(cfg))
|
||||
}
|
||||
@@ -22,7 +22,7 @@ type LogProxyHandler struct {
|
||||
ds *LogDSHandler
|
||||
}
|
||||
|
||||
const maxLogQueryLimit = 500
|
||||
const maxLogQueryLimit = 1000 // 单次查询返回日志行数硬上限(Loki limit / ES size 共用)// Logan.Li
|
||||
|
||||
func NewLogProxyHandler(ds *LogDSHandler) *LogProxyHandler {
|
||||
return &LogProxyHandler{ds: ds}
|
||||
|
||||
@@ -2,6 +2,7 @@ package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"ezops/model"
|
||||
|
||||
@@ -47,6 +48,16 @@ func (h *SessionCommandHandler) ListSessionCommands(c *gin.Context) {
|
||||
if req.Command != "" {
|
||||
query = query.Where("command LIKE ?", "%"+req.Command+"%")
|
||||
}
|
||||
if req.StartDate != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", req.StartDate, time.Local); err == nil {
|
||||
query = query.Where("timestamp >= ?", t)
|
||||
}
|
||||
}
|
||||
if req.EndDate != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", req.EndDate, time.Local); err == nil {
|
||||
query = query.Where("timestamp < ?", t.Add(24*time.Hour))
|
||||
}
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
@@ -54,17 +65,43 @@ func (h *SessionCommandHandler) ListSessionCommands(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var commands []model.SessionCommand
|
||||
if err := query.Order("id DESC").
|
||||
// 联表带出会话录制大小,前端可判断能否回放 // Logan.Li
|
||||
var items []model.SessionCommandItem
|
||||
listQuery := h.db.Table("sys_session_commands AS sc").
|
||||
Select("sc.*, COALESCE(ts.record_size, 0) AS session_record_size").
|
||||
Joins("LEFT JOIN sys_terminal_sessions AS ts ON ts.id = sc.session_id")
|
||||
if req.SessionID > 0 {
|
||||
listQuery = listQuery.Where("sc.session_id = ?", req.SessionID)
|
||||
}
|
||||
if req.Username != "" {
|
||||
listQuery = listQuery.Where("sc.username LIKE ?", "%"+req.Username+"%")
|
||||
}
|
||||
if req.ServerID > 0 {
|
||||
listQuery = listQuery.Where("sc.server_id = ?", req.ServerID)
|
||||
}
|
||||
if req.Command != "" {
|
||||
listQuery = listQuery.Where("sc.command LIKE ?", "%"+req.Command+"%")
|
||||
}
|
||||
if req.StartDate != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", req.StartDate, time.Local); err == nil {
|
||||
listQuery = listQuery.Where("sc.timestamp >= ?", t)
|
||||
}
|
||||
}
|
||||
if req.EndDate != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", req.EndDate, time.Local); err == nil {
|
||||
listQuery = listQuery.Where("sc.timestamp < ?", t.Add(24*time.Hour))
|
||||
}
|
||||
}
|
||||
if err := listQuery.Order("sc.id DESC").
|
||||
Offset((req.Page - 1) * req.PageSize).
|
||||
Limit(req.PageSize).
|
||||
Find(&commands).Error; err != nil {
|
||||
Scan(&items).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "查询失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: model.ListResponse{
|
||||
List: commands,
|
||||
List: items,
|
||||
Total: total,
|
||||
}})
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ezops/middleware"
|
||||
"ezops/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -48,6 +50,26 @@ func (h *TerminalSessionHandler) ListTerminalSessions(c *gin.Context) {
|
||||
if req.Status != "" {
|
||||
query = query.Where("status = ?", req.Status)
|
||||
}
|
||||
if kw := strings.TrimSpace(req.Keyword); kw != "" {
|
||||
like := "%" + kw + "%"
|
||||
query = query.Where(
|
||||
"username LIKE ? OR server_name LIKE ? OR server_ip LIKE ? OR client_ip LIKE ?",
|
||||
like, like, like, like,
|
||||
)
|
||||
}
|
||||
if req.StartDate != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", req.StartDate, time.Local); err == nil {
|
||||
query = query.Where("start_time >= ?", t)
|
||||
}
|
||||
}
|
||||
if req.EndDate != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", req.EndDate, time.Local); err == nil {
|
||||
query = query.Where("start_time < ?", t.Add(24*time.Hour))
|
||||
}
|
||||
}
|
||||
if req.HasRecord == "1" || strings.EqualFold(req.HasRecord, "true") {
|
||||
query = query.Where("record_size > 0")
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
@@ -173,11 +195,17 @@ func (h *TerminalSessionHandler) KillSession(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 强制终止 WebShell 连接
|
||||
// 强制终止 WebShell 连接;在线连接由 webshell finalize 落盘录制,勿抢先 UPDATE 覆盖 // Logan.Li
|
||||
killed := false
|
||||
if h.killFunc != nil {
|
||||
h.killFunc(session.ID)
|
||||
killed = h.killFunc(session.ID)
|
||||
}
|
||||
if killed {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已终止"})
|
||||
return
|
||||
}
|
||||
|
||||
// 僵尸会话(连接已断开、服务重启等):无录制可落盘,仅关闭记录
|
||||
now := time.Now()
|
||||
duration := int(now.Sub(session.StartTime).Seconds())
|
||||
if err := h.db.Model(&session).Updates(map[string]interface{}{
|
||||
@@ -222,6 +250,36 @@ func (h *TerminalSessionHandler) DownloadSession(c *gin.Context) {
|
||||
c.String(http.StatusOK, session.RecordData)
|
||||
}
|
||||
|
||||
// LogReplayView 记录终端回放查看审计(二次审计留痕) // Logan.Li
|
||||
// POST /api/v1/terminal-sessions/:id/replay-view
|
||||
func (h *TerminalSessionHandler) LogReplayView(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的会话ID"})
|
||||
return
|
||||
}
|
||||
var session model.TerminalSession
|
||||
if err := h.db.Select("id", "username", "server_name", "server_ip").First(&session, id).Error; err != nil {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 404, Message: "会话不存在"})
|
||||
return
|
||||
}
|
||||
uid := middleware.GetUserID(c)
|
||||
log := model.AuditLog{
|
||||
UserID: &uid,
|
||||
Username: middleware.GetUsername(c),
|
||||
IP: c.ClientIP(),
|
||||
UserAgent: c.GetHeader("User-Agent"),
|
||||
Module: "terminal_audit",
|
||||
Action: "replay_view",
|
||||
Resource: "terminal_session",
|
||||
ResourceID: strconv.FormatUint(id, 10),
|
||||
Request: fmt.Sprintf("回放终端会话 #%d(%s → %s/%s)", id, session.Username, session.ServerName, session.ServerIP),
|
||||
Status: 200,
|
||||
}
|
||||
_ = h.db.Create(&log).Error // 落库失败不阻断回放
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "ok"})
|
||||
}
|
||||
|
||||
// StreamSession 终端会话实时流(WebSocket 暂未实现)
|
||||
// GET /api/v1/terminal-sessions/:id/stream
|
||||
func (h *TerminalSessionHandler) StreamSession(c *gin.Context) {
|
||||
|
||||
@@ -190,23 +190,50 @@ func (h *WebShellHandler) Terminal(c *gin.Context) {
|
||||
recorder := newAsciicastRecorder()
|
||||
const maxRecordSize = 1024 * 1024
|
||||
var cmdBuf strings.Builder
|
||||
sessionStatus := "closed"
|
||||
var finalizeOnce sync.Once
|
||||
|
||||
finalizeSession := func(status string) {
|
||||
endTime := time.Now()
|
||||
duration := int(endTime.Sub(termSession.StartTime).Seconds())
|
||||
recData := recorder.String()
|
||||
recSize := recorder.Len()
|
||||
log.Printf("[WebShell] Finalize session=%d status=%s duration=%ds rec=%d bytes", termSession.ID, status, duration, recSize)
|
||||
// Raw SQL with error logging
|
||||
result := h.db.Exec("UPDATE sys_terminal_sessions SET status=?, duration=?, end_time=?, record_data=?, record_size=? WHERE id=?",
|
||||
status, duration, endTime, recData, recSize, termSession.ID)
|
||||
if result.Error != nil {
|
||||
log.Printf("[WebShell] ERROR finalize session=%d: %v", termSession.ID, result.Error)
|
||||
} else {
|
||||
log.Printf("[WebShell] Finalize OK session=%d rows_affected=%d", termSession.ID, result.RowsAffected)
|
||||
// 命令输出字节统计:每条命令对应「下一条命令前」的 stdout 累计量 // Logan.Li
|
||||
var cmdTrack struct {
|
||||
mu sync.Mutex
|
||||
lastCmdID uint
|
||||
stdoutBytes int
|
||||
}
|
||||
flushCmdOutput := func() {
|
||||
cmdTrack.mu.Lock()
|
||||
prevID := cmdTrack.lastCmdID
|
||||
bytes := cmdTrack.stdoutBytes
|
||||
cmdTrack.stdoutBytes = 0
|
||||
cmdTrack.mu.Unlock()
|
||||
if prevID > 0 {
|
||||
_ = h.db.Model(&model.SessionCommand{}).Where("id = ?", prevID).
|
||||
Update("output_size", bytes).Error
|
||||
}
|
||||
}
|
||||
|
||||
// finalizeSession 会话结束落盘(sync.Once 防重复写入) // Logan.Li
|
||||
finalizeSession := func(status string) {
|
||||
sessionStatus = status
|
||||
finalizeOnce.Do(func() {
|
||||
endTime := time.Now()
|
||||
duration := int(endTime.Sub(termSession.StartTime).Seconds())
|
||||
recData := recorder.String()
|
||||
recSize := recorder.Len()
|
||||
log.Printf("[WebShell] Finalize session=%d status=%s duration=%ds rec=%d bytes", termSession.ID, status, duration, recSize)
|
||||
result := h.db.Exec("UPDATE sys_terminal_sessions SET status=?, duration=?, end_time=?, record_data=?, record_size=? WHERE id=?",
|
||||
status, duration, endTime, recData, recSize, termSession.ID)
|
||||
if result.Error != nil {
|
||||
log.Printf("[WebShell] ERROR finalize session=%d: %v", termSession.ID, result.Error)
|
||||
} else {
|
||||
log.Printf("[WebShell] Finalize OK session=%d rows_affected=%d", termSession.ID, result.RowsAffected)
|
||||
}
|
||||
})
|
||||
}
|
||||
defer func() {
|
||||
flushCmdOutput()
|
||||
finalizeSession(sessionStatus)
|
||||
}()
|
||||
|
||||
connectingMsg := "\r\n\x1b[36mConnecting to " + server.Hostname + " (" + server.IP + ":" + strconv.Itoa(server.Port) + ")...\x1b[0m\r\n"
|
||||
conn.WriteJSON(WSMessage{Status: 0, Data: connectingMsg})
|
||||
|
||||
@@ -335,6 +362,9 @@ func (h *WebShellHandler) Terminal(c *gin.Context) {
|
||||
if recorder.Len() < maxRecordSize {
|
||||
recorder.WriteRow(data)
|
||||
}
|
||||
cmdTrack.mu.Lock()
|
||||
cmdTrack.stdoutBytes += n
|
||||
cmdTrack.mu.Unlock()
|
||||
if h.auditHub != nil {
|
||||
h.auditHub.Broadcast(termSession.ID, data)
|
||||
}
|
||||
@@ -366,13 +396,21 @@ func (h *WebShellHandler) Terminal(c *gin.Context) {
|
||||
case 0:
|
||||
if msg.Data != "" {
|
||||
stdinPipe.Write([]byte(msg.Data))
|
||||
// 录入用户输入,回放可见键入内容 // Logan.Li
|
||||
if recorder.Len() < maxRecordSize {
|
||||
recorder.WriteRow([]byte(msg.Data))
|
||||
}
|
||||
|
||||
// Command extraction from stdin
|
||||
cmdBuf.WriteString(msg.Data)
|
||||
if strings.Contains(msg.Data, "\r") || strings.Contains(msg.Data, "\n") {
|
||||
cmd := extractCommand(cmdBuf.String())
|
||||
if cmd != "" {
|
||||
recordCommand(h.db, termSession.ID, termSession.ServerID, usernameStr, cmd)
|
||||
flushCmdOutput()
|
||||
newID := recordCommand(h.db, termSession.ID, termSession.ServerID, termSession.ServerName, usernameStr, cmd)
|
||||
cmdTrack.mu.Lock()
|
||||
cmdTrack.lastCmdID = newID
|
||||
cmdTrack.mu.Unlock()
|
||||
}
|
||||
cmdBuf.Reset()
|
||||
}
|
||||
@@ -392,10 +430,16 @@ func (h *WebShellHandler) Terminal(c *gin.Context) {
|
||||
sshClient.Close()
|
||||
<-done
|
||||
stdinPipe.Close()
|
||||
finalizeSession("closed")
|
||||
sessionStatus = "closed"
|
||||
log.Printf("[WebShell] Disconnected %s (%s)", server.Hostname, addr)
|
||||
}
|
||||
|
||||
// shortShellCmds 常见 2 字符 shell 命令白名单 // Logan.Li
|
||||
var shortShellCmds = map[string]bool{
|
||||
"ls": true, "cd": true, "ps": true, "df": true, "id": true, "wc": true,
|
||||
"mv": true, "cp": true, "vi": true, "sh": true, "ll": true, "pwd": true,
|
||||
}
|
||||
|
||||
// extractCommand extracts a clean command from raw terminal input
|
||||
func extractCommand(input string) string {
|
||||
cleaned := strings.Map(func(r rune) rune {
|
||||
@@ -411,25 +455,31 @@ func extractCommand(input string) string {
|
||||
if len(cleaned) < 2 || len(cleaned) > 500 {
|
||||
return ""
|
||||
}
|
||||
// 过滤输入中途误触回车产生的 2 字符残片(如 up/uo),保留 ls/cd 等 // Logan.Li
|
||||
if len(cleaned) == 2 && !shortShellCmds[cleaned] {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(cleaned, "\x1b[") {
|
||||
return ""
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// recordCommand records a terminal command to sys_session_commands
|
||||
func recordCommand(db *gorm.DB, sessionID, serverID uint, username, command string) {
|
||||
// recordCommand 写入命令审计并返回记录 ID // Logan.Li
|
||||
func recordCommand(db *gorm.DB, sessionID, serverID uint, serverName, username, command string) uint {
|
||||
now := time.Now()
|
||||
cmd := model.SessionCommand{
|
||||
SessionID: sessionID,
|
||||
ServerID: serverID,
|
||||
Username: username,
|
||||
Command: command,
|
||||
Timestamp: now,
|
||||
SessionID: sessionID,
|
||||
ServerID: serverID,
|
||||
ServerName: serverName,
|
||||
Username: username,
|
||||
Command: command,
|
||||
Timestamp: now,
|
||||
}
|
||||
if err := db.Create(&cmd).Error; err != nil {
|
||||
log.Printf("[WebShell] Command record failed: %v", err)
|
||||
} else {
|
||||
log.Printf("[WebShell] Command recorded session=%d cmd=%q", sessionID, command)
|
||||
return 0
|
||||
}
|
||||
log.Printf("[WebShell] Command recorded session=%d cmd=%q", sessionID, command)
|
||||
return cmd.ID
|
||||
}
|
||||
+14
-12
@@ -12,10 +12,11 @@ type LLMConfig struct {
|
||||
Endpoint string `gorm:"size:500" json:"endpoint"` // 基础地址,如 https://api.openai.com/v1
|
||||
Temperature float64 `gorm:"default:0.7" json:"temperature"` // 采样温度 0-2
|
||||
MaxTokens int `gorm:"default:2000" json:"max_tokens"` // 最大输出 token
|
||||
SystemPrompt string `gorm:"type:text" json:"system_prompt"` // 系统提示词(AIOps 运维助手角色)
|
||||
Enabled bool `gorm:"default:false" json:"enabled"` // 是否启用
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
SystemPrompt string `gorm:"type:text" json:"system_prompt"` // 系统提示词(AIOps 运维助手角色)
|
||||
Enabled bool `gorm:"default:false" json:"enabled"` // 是否启用
|
||||
ReasoningEnabled bool `gorm:"default:false" json:"reasoning_enabled"` // 是否启用推理/Plan 模式(OpenAI o-series / DeepSeek R1 / Claude thinking 等) // Logan.Li
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 表名 // Logan.Li
|
||||
@@ -23,14 +24,15 @@ func (LLMConfig) TableName() string { return "ai_llm_configs" }
|
||||
|
||||
// SaveLLMConfigRequest 保存 LLM 配置请求 // Logan.Li
|
||||
type SaveLLMConfigRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
APIKey string `json:"api_key"` // 若为脱敏值(含 ****)则后端保留原值
|
||||
Endpoint string `json:"endpoint"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
SystemPrompt string `json:"system_prompt"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
APIKey string `json:"api_key"` // 若为脱敏值(含 ****)则后端保留原值
|
||||
Endpoint string `json:"endpoint"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
SystemPrompt string `json:"system_prompt"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ReasoningEnabled bool `json:"reasoning_enabled"` // 是否启用推理/Plan 模式
|
||||
}
|
||||
|
||||
// ChatMessage 对话消息 // Logan.Li
|
||||
|
||||
@@ -216,4 +216,10 @@ type ServerStats struct {
|
||||
AvgCPU float64 `json:"avg_cpu"`
|
||||
AvgMemory float64 `json:"avg_memory"`
|
||||
AvgDisk float64 `json:"avg_disk"`
|
||||
// 总资源概览(GB/核),用于仪表盘资源使用率卡片展示具体容量 // Logan.Li
|
||||
TotalCPU int64 `json:"total_cpu"` // CPU 核心数合计
|
||||
TotalMemory int64 `json:"total_memory"` // 内存总量 GB
|
||||
UsedMemory int64 `json:"used_memory"` // 内存已用 GB
|
||||
TotalDisk int64 `json:"total_disk"` // 磁盘总量 GB
|
||||
UsedDisk int64 `json:"used_disk"` // 磁盘已用 GB
|
||||
}
|
||||
@@ -11,6 +11,7 @@ type SessionCommand struct {
|
||||
ServerID uint `json:"server_id"`
|
||||
ServerName string `gorm:"size:128" json:"server_name"`
|
||||
Command string `gorm:"type:text" json:"command"` // 执行的原始命令
|
||||
OutputSize *int `json:"output_size"` // 下一条命令前累计的终端输出字节数,nil=历史未统计 // Logan.Li
|
||||
Timestamp time.Time `json:"timestamp"` // 命令执行时间
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -19,12 +20,14 @@ func (SessionCommand) TableName() string {
|
||||
return "sys_session_commands"
|
||||
}
|
||||
|
||||
// SessionCommandQuery 会话命令查询
|
||||
// SessionCommandQuery 会话命令查询 // Logan.Li
|
||||
type SessionCommandQuery struct {
|
||||
SessionID uint `form:"session_id"`
|
||||
Username string `form:"username"`
|
||||
ServerID uint `form:"server_id"`
|
||||
Command string `form:"command"`
|
||||
StartDate string `form:"start_date"` // YYYY-MM-DD
|
||||
EndDate string `form:"end_date"`
|
||||
Page int `form:"page,default=1"`
|
||||
PageSize int `form:"page_size,default=20"`
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package model
|
||||
|
||||
// SessionCommandItem 命令列表项(附带关联会话录制大小) // Logan.Li
|
||||
type SessionCommandItem struct {
|
||||
SessionCommand
|
||||
SessionRecordSize int `gorm:"column:session_record_size" json:"session_record_size"` // 关联会话 record_size,0=无录制 // Logan.Li
|
||||
}
|
||||
@@ -26,11 +26,15 @@ func (TerminalSession) TableName() string {
|
||||
return "sys_terminal_sessions"
|
||||
}
|
||||
|
||||
// TerminalSessionQuery 终端会话查询
|
||||
// TerminalSessionQuery 终端会话查询 // Logan.Li
|
||||
type TerminalSessionQuery struct {
|
||||
ServerID uint `form:"server_id"`
|
||||
Username string `form:"username"`
|
||||
Status string `form:"status"`
|
||||
Page int `form:"page,default=1"`
|
||||
PageSize int `form:"page_size,default=20"`
|
||||
ServerID uint `form:"server_id"`
|
||||
Username string `form:"username"`
|
||||
Status string `form:"status"`
|
||||
Keyword string `form:"keyword"` // 模糊匹配用户/主机名/IP/客户端IP
|
||||
StartDate string `form:"start_date"` // YYYY-MM-DD
|
||||
EndDate string `form:"end_date"` // YYYY-MM-DD
|
||||
HasRecord string `form:"has_record"` // 1/true 仅含录制
|
||||
Page int `form:"page,default=1"`
|
||||
PageSize int `form:"page_size,default=20"`
|
||||
}
|
||||
@@ -11,6 +11,7 @@ package agentcli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"ezops/pkg/agentcli/core"
|
||||
"ezops/pkg/agentcli/evolver"
|
||||
@@ -54,9 +55,13 @@ func NewEngine(cfg EngineConfig) (*Engine, error) {
|
||||
// LLM 客户端
|
||||
client := llm.NewClient(cfg.LLM)
|
||||
|
||||
// 工具注册中心:ssh_execute(注入目标主机凭据)+ analyze_output
|
||||
// 工具注册中心:ssh_execute(SSH 长连接)+ analyze_output
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewSSHExecuteTool(cfg.SSH, coreCfg.ToolTimeout))
|
||||
sshTool, err := tools.NewSSHExecuteTool(cfg.SSH, coreCfg.ToolTimeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ssh_execute 初始化失败: %w", err)
|
||||
}
|
||||
registry.Register(sshTool)
|
||||
registry.Register(tools.NewAnalyzeOutputTool())
|
||||
|
||||
// 安全控制器
|
||||
@@ -81,16 +86,9 @@ func (e *Engine) SetConfirmFn(fn safety.ConfirmFn) { e.safety.SetConfirmFn(fn) }
|
||||
// SetAutoApprove 测试用:自动批准(生产禁用) // Logan.Li
|
||||
func (e *Engine) SetAutoApprove(b bool) { e.safety.SetAutoApprove(b) }
|
||||
|
||||
// Run 执行任务(ReAct 循环),serverInfo 为目标主机摘要(注入 system prompt) // Logan.Li
|
||||
func (e *Engine) Run(ctx context.Context, query, serverInfo string) (string, error) {
|
||||
res, err := e.agent.Run(ctx, query, serverInfo)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if res != nil {
|
||||
return res.Output, nil
|
||||
}
|
||||
return "", nil
|
||||
// Run 执行任务(ReAct 循环);prior 为上一轮对话历史(不含 system),priorTokens 为累计 token // Logan.Li
|
||||
func (e *Engine) Run(ctx context.Context, query, serverInfo string, prior []llm.Message, priorTokens int) (*core.RunResult, error) {
|
||||
return e.agent.Run(ctx, query, serverInfo, prior, priorTokens)
|
||||
}
|
||||
|
||||
// EvolveStats 进化统计(供审计展示) // Logan.Li
|
||||
@@ -101,8 +99,11 @@ func (e *Engine) EvolveStats() map[string]interface{} {
|
||||
return e.evolver.GetStats()
|
||||
}
|
||||
|
||||
// Close 释放资源 // Logan.Li
|
||||
// Close 释放资源(含 SSH 长连接) // Logan.Li
|
||||
func (e *Engine) Close() {
|
||||
if e.tools != nil {
|
||||
e.tools.Close()
|
||||
}
|
||||
if e.agent != nil {
|
||||
e.agent.Close()
|
||||
}
|
||||
|
||||
@@ -23,8 +23,9 @@ import (
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventThought EventType = "thought" // AI 思考/推理文本
|
||||
EventToolCall EventType = "tool_call" // 即将执行的工具(名/参数/风险)
|
||||
EventThought EventType = "thought" // AI 思考/推理文本(流式结束后的完整内容)
|
||||
EventThoughtDelta EventType = "thought_delta" // AI 思考流式增量 token
|
||||
EventToolCall EventType = "tool_call" // 即将执行的工具(名/参数/风险)
|
||||
EventToolResult EventType = "tool_result" // 工具执行结果(输出/成败/耗时)
|
||||
EventProgress EventType = "progress" // 轮次进度 + token
|
||||
EventDone EventType = "done" // 任务完成(最终答案)
|
||||
@@ -50,6 +51,14 @@ type Event struct {
|
||||
// EventCallback 事件回调(controller 注入) // Logan.Li
|
||||
type EventCallback func(Event)
|
||||
|
||||
// RunResult ReAct 执行结果(含对话历史供多轮追问) // Logan.Li
|
||||
type RunResult struct {
|
||||
Success bool
|
||||
Output string
|
||||
History []llm.Message // system 之后的对话历史
|
||||
TotalTokens int
|
||||
}
|
||||
|
||||
// Config Agent 运行配置 // Logan.Li
|
||||
type Config struct {
|
||||
MaxIterations int
|
||||
@@ -60,6 +69,7 @@ type Config struct {
|
||||
OutputMaxLength int
|
||||
MaxContextTokens int
|
||||
MemoryDir string // Evolver 记忆目录
|
||||
RoundOffset int // 轮次偏移(多轮追问时接续上一轮编号) // Logan.Li
|
||||
}
|
||||
|
||||
// DefaultConfig 默认配置 // Logan.Li
|
||||
@@ -112,9 +122,9 @@ func (a *Agent) emit(e Event) {
|
||||
}
|
||||
}
|
||||
|
||||
// Run 执行单次任务(ReAct 循环 + Evolver 集成) // Logan.Li
|
||||
func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Result, error) {
|
||||
a.totalTokens = 0
|
||||
// Run 执行单次任务(ReAct 循环 + Evolver 集成);prior 为不含 system 的历史对话 // Logan.Li
|
||||
func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.Message, priorTokens int) (*RunResult, error) {
|
||||
a.totalTokens = priorTokens
|
||||
start := time.Now()
|
||||
|
||||
// Evolver PREDICT:获取历史经验注入 system prompt
|
||||
@@ -127,10 +137,11 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul
|
||||
toolDescs := a.buildToolDescriptions()
|
||||
systemPrompt := prompt.BuildSystemPrompt(serverInfo, toolDescs, evolveCtx)
|
||||
|
||||
messages := []llm.Message{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: query},
|
||||
messages := []llm.Message{{Role: "system", Content: systemPrompt}}
|
||||
if len(prior) > 0 {
|
||||
messages = append(messages, prior...)
|
||||
}
|
||||
messages = append(messages, llm.Message{Role: "user", Content: query})
|
||||
llmTools := a.registry.ToLLMTools()
|
||||
|
||||
toolCallHistory := make(map[string]int) // 循环检测
|
||||
@@ -144,7 +155,7 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.emit(Event{Type: EventProgress, Round: iter + 1})
|
||||
a.emit(Event{Type: EventProgress, Round: a.roundNum(iter)})
|
||||
|
||||
// 自适应检查点
|
||||
if hint := a.checkAdaptiveCheckpoint(iter, toolCallHistory, consecutiveFailures, totalToolCalls); hint != "" {
|
||||
@@ -154,12 +165,17 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul
|
||||
// 裁剪历史
|
||||
trimmed := a.trimMessages(messages)
|
||||
|
||||
// 调用 LLM
|
||||
resp, err := a.llm.Complete(ctx, &llm.CompletionRequest{
|
||||
// 调用 LLM(流式推送思考增量,降低长推理等待感) // Logan.Li
|
||||
round := a.roundNum(iter)
|
||||
resp, err := a.llm.Stream(ctx, &llm.CompletionRequest{
|
||||
Messages: trimmed,
|
||||
Tools: llmTools,
|
||||
Temperature: a.config.Temperature,
|
||||
MaxTokens: a.config.MaxTokens,
|
||||
}, func(delta string) {
|
||||
if strings.TrimSpace(delta) != "" {
|
||||
a.emit(Event{Type: EventThoughtDelta, Round: round, Thought: delta})
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
a.emit(Event{Type: EventError, Message: "LLM 调用失败: " + err.Error()})
|
||||
@@ -169,9 +185,9 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul
|
||||
a.totalTokens += resp.Usage.TotalTokens
|
||||
a.emit(Event{Type: EventProgress, Tokens: a.totalTokens})
|
||||
|
||||
// 推送思考文本
|
||||
// 推送完整思考文本(供持久化与回放) // Logan.Li
|
||||
if strings.TrimSpace(resp.Message.Content) != "" {
|
||||
a.emit(Event{Type: EventThought, Round: iter + 1, Thought: resp.Message.Content})
|
||||
a.emit(Event{Type: EventThought, Round: round, Thought: resp.Message.Content})
|
||||
}
|
||||
messages = append(messages, resp.Message)
|
||||
|
||||
@@ -180,11 +196,14 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul
|
||||
answer := resp.Message.Content
|
||||
a.emit(Event{Type: EventDone, Answer: answer, Tokens: a.totalTokens})
|
||||
a.triggerEvolve(ctx, query, serverInfo, answer, toolRecords, true, start)
|
||||
return &tools.Result{Success: true, Output: answer}, nil
|
||||
return &RunResult{
|
||||
Success: true, Output: answer,
|
||||
History: a.extractHistory(messages), TotalTokens: a.totalTokens,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 处理工具调用
|
||||
observations, records, failInc := a.processToolCalls(ctx, resp.Message.ToolCalls, toolCallHistory, iter+1)
|
||||
observations, records, failInc := a.processToolCalls(ctx, resp.Message.ToolCalls, toolCallHistory, round)
|
||||
toolRecords = append(toolRecords, records...)
|
||||
messages = append(messages, observations...)
|
||||
totalToolCalls += len(resp.Message.ToolCalls)
|
||||
@@ -195,7 +214,9 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul
|
||||
msg := fmt.Sprintf("达到最大迭代次数(%d),任务未能完成。可能任务过于复杂或需要更多步骤。", a.config.MaxIterations)
|
||||
a.emit(Event{Type: EventError, Message: msg, Tokens: a.totalTokens})
|
||||
a.triggerEvolve(ctx, query, serverInfo, "", toolRecords, false, start)
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
return &RunResult{
|
||||
Success: false, History: a.extractHistory(messages), TotalTokens: a.totalTokens,
|
||||
}, fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
// processToolCalls 处理工具调用,返回 observation 消息 + 记录 // Logan.Li
|
||||
@@ -264,6 +285,13 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist
|
||||
success = false
|
||||
}
|
||||
|
||||
// 超长 ssh 输出自动分析,避免 token 爆炸 // Logan.Li
|
||||
if tc.Function.Name == "ssh_execute" && success && len(output) > a.autoAnalyzeThreshold() {
|
||||
if analyzed := a.autoAnalyzeOutput(ctx, output); analyzed != "" {
|
||||
output = analyzed
|
||||
}
|
||||
}
|
||||
|
||||
// 输出截断
|
||||
if len(output) > a.config.OutputMaxLength {
|
||||
output = output[:a.config.OutputMaxLength] +
|
||||
@@ -313,6 +341,43 @@ func (a *Agent) triggerEvolve(ctx context.Context, query, serverHost, answer str
|
||||
}()
|
||||
}
|
||||
|
||||
// roundNum 计算对外展示的 ReAct 轮次(含多轮追问偏移) // Logan.Li
|
||||
func (a *Agent) roundNum(iter int) int {
|
||||
return a.config.RoundOffset + iter + 1
|
||||
}
|
||||
|
||||
// extractHistory 提取 system 之后的对话历史(供多轮追问) // Logan.Li
|
||||
func (a *Agent) extractHistory(messages []llm.Message) []llm.Message {
|
||||
if len(messages) <= 1 {
|
||||
return nil
|
||||
}
|
||||
out := make([]llm.Message, len(messages)-1)
|
||||
copy(out, messages[1:])
|
||||
return out
|
||||
}
|
||||
|
||||
// autoAnalyzeThreshold 触发自动 analyze_output 的输出长度阈值 // Logan.Li
|
||||
func (a *Agent) autoAnalyzeThreshold() int {
|
||||
t := a.config.OutputMaxLength / 2
|
||||
if t < 4000 {
|
||||
return 4000
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// autoAnalyzeOutput 对超长命令输出自动调用 analyze_output // Logan.Li
|
||||
func (a *Agent) autoAnalyzeOutput(ctx context.Context, output string) string {
|
||||
tool, err := a.registry.Get("analyze_output")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
r, err := tool.Execute(ctx, map[string]interface{}{"output": output})
|
||||
if err != nil || r == nil || !r.Success || strings.TrimSpace(r.Output) == "" {
|
||||
return ""
|
||||
}
|
||||
return r.Output + "\n[系统自动分析超长输出]"
|
||||
}
|
||||
|
||||
// buildToolDescriptions 工具描述文本 // Logan.Li
|
||||
func (a *Agent) buildToolDescriptions() string {
|
||||
var sb strings.Builder
|
||||
@@ -428,9 +493,5 @@ func estimateTokens(s string) int {
|
||||
return len([]rune(s))/3 + 1
|
||||
}
|
||||
|
||||
// Close 释放资源 // Logan.Li
|
||||
func (a *Agent) Close() {
|
||||
if a.registry != nil {
|
||||
// Registry 无 Closer 实现(Web 工具无连接池),保留接口位
|
||||
}
|
||||
}
|
||||
// Close 释放资源(SSH 长连接由 Engine.Close 统一释放) // Logan.Li
|
||||
func (a *Agent) Close() {}
|
||||
@@ -42,6 +42,7 @@ type EvolveResult struct {
|
||||
LearnedHint string `json:"learned_hint"`
|
||||
ToolSequence []string `json:"tool_sequence"`
|
||||
ExperienceAdded bool `json:"experience_added"`
|
||||
SkillAdded bool `json:"skill_added"`
|
||||
Consolidated bool `json:"consolidated"`
|
||||
}
|
||||
|
||||
@@ -107,6 +108,21 @@ func (e *EvolverEngine) Evolve(ctx context.Context, exec *TaskExecution) *Evolve
|
||||
go e.memorizeFact(ctx, exec) // 异步,不阻塞进化主流程
|
||||
}
|
||||
|
||||
// Step 6: PROCEDURAL —— 成功任务沉淀程序记忆(工具命令序列) // Logan.Li
|
||||
if exec.Success {
|
||||
if steps := extractSSHSteps(exec.ToolCalls); len(steps) >= 2 {
|
||||
skill := SkillEntry{
|
||||
Name: result.TaskType + "标准流程",
|
||||
When: result.TaskType,
|
||||
Steps: steps,
|
||||
Timestamp: time.Now().Unix(),
|
||||
}
|
||||
if err := e.mem.AddSkill(skill); err == nil {
|
||||
result.SkillAdded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -194,6 +210,12 @@ func (e *EvolverEngine) GetContextForPrompt(query string) string {
|
||||
if hosts := e.mem.Hosts(); len(hosts) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" 【已知主机】%s\n", strings.Join(hosts, ", ")))
|
||||
}
|
||||
if skills := e.mem.FindSkills(taskType, 2); len(skills) > 0 {
|
||||
sb.WriteString(" 【推荐流程】\n")
|
||||
for _, sk := range skills {
|
||||
sb.WriteString(fmt.Sprintf(" - %s: %s\n", sk.Name, strings.Join(sk.Steps, " → ")))
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
@@ -258,6 +280,20 @@ func resultToolNames(calls []ToolCallRecord) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// extractSSHSteps 从工具调用记录提取 ssh_execute 命令序列 // Logan.Li
|
||||
func extractSSHSteps(calls []ToolCallRecord) []string {
|
||||
out := make([]string, 0, len(calls))
|
||||
for _, c := range calls {
|
||||
if c.ToolName != "ssh_execute" {
|
||||
continue
|
||||
}
|
||||
if cmd, ok := c.Args["command"].(string); ok && strings.TrimSpace(cmd) != "" {
|
||||
out = append(out, strings.TrimSpace(cmd))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func uniqueStrings(in []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
out := make([]string, 0, len(in))
|
||||
|
||||
@@ -235,6 +235,21 @@ func (m *Memories) Hosts() []string {
|
||||
return readJSON(m.envPath, KnownHosts{}).Hosts
|
||||
}
|
||||
|
||||
// FindSkills 按任务类型查找程序记忆(运维流程,最多 n 条) // Logan.Li
|
||||
func (m *Memories) FindSkills(taskType string, n int) []SkillEntry {
|
||||
all := readJSONL[SkillEntry](m.sklPath)
|
||||
var matched []SkillEntry
|
||||
for _, s := range all {
|
||||
if taskType == "" || s.When == taskType {
|
||||
matched = append(matched, s)
|
||||
}
|
||||
}
|
||||
if len(matched) > n {
|
||||
matched = matched[len(matched)-n:]
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
// CountStats 统计各层记忆条目数 // Logan.Li
|
||||
func (m *Memories) CountStats() map[string]int {
|
||||
return map[string]int{
|
||||
|
||||
@@ -42,6 +42,12 @@ type anthropicMessage struct {
|
||||
Content json.RawMessage `json:"content"`
|
||||
}
|
||||
|
||||
// anthropicThinking 启用 Claude thinking 模式(Claude 3.7 Sonnet 支持) // Logan.Li
|
||||
type anthropicThinking struct {
|
||||
Type string `json:"type"`
|
||||
BudgetTokens int `json:"budget_tokens"`
|
||||
}
|
||||
|
||||
// anthropicRequest Anthropic /v1/messages 请求体 // Logan.Li
|
||||
type anthropicRequest struct {
|
||||
Model string `json:"model"`
|
||||
@@ -51,6 +57,7 @@ type anthropicRequest struct {
|
||||
Tools []anthropicTool `json:"tools,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Thinking *anthropicThinking `json:"thinking,omitempty"` // Claude 3.7 Sonnet 推理模式 // Logan.Li
|
||||
}
|
||||
|
||||
// anthropicTool Anthropic 工具定义(input_schema,对应 OpenAI 的 parameters) // Logan.Li
|
||||
@@ -157,6 +164,12 @@ func buildTools(tools []Tool) []anthropicTool {
|
||||
return out
|
||||
}
|
||||
|
||||
// isAnthropicThinkingModel 判断是否为支持 thinking 的 Claude 模型 // Logan.Li
|
||||
func isAnthropicThinkingModel(model string) bool {
|
||||
m := strings.ToLower(model)
|
||||
return strings.Contains(m, "claude-3-7") || strings.Contains(m, "claude-3.7")
|
||||
}
|
||||
|
||||
// buildRequest 构造 Anthropic 请求体(Complete 与 Stream 共用) // Logan.Li
|
||||
func (c *AnthropicClient) buildRequest(req *CompletionRequest, stream bool) ([]byte, error) {
|
||||
system, msgs := c.buildMessages(req)
|
||||
@@ -168,6 +181,19 @@ func (c *AnthropicClient) buildRequest(req *CompletionRequest, stream bool) ([]b
|
||||
if payload.MaxTokens <= 0 {
|
||||
payload.MaxTokens = 4096
|
||||
}
|
||||
// 启用 Claude thinking 模式时,temperature 必须固定为 1,max_tokens 需大于 budget_tokens // Logan.Li
|
||||
enabled := req.ReasoningEnabled || c.cfg.ReasoningEnabled
|
||||
if enabled && isAnthropicThinkingModel(payload.Model) {
|
||||
payload.Temperature = 1
|
||||
budget := payload.MaxTokens / 2
|
||||
if budget < 1024 {
|
||||
budget = 1024
|
||||
}
|
||||
if payload.MaxTokens <= budget {
|
||||
payload.MaxTokens = budget + 1024
|
||||
}
|
||||
payload.Thinking = &anthropicThinking{Type: "enabled", BudgetTokens: budget}
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化请求失败: %w", err)
|
||||
|
||||
@@ -17,13 +17,14 @@ import (
|
||||
|
||||
// Config LLM 客户端配置(endpoint 已由上层解析为完整 base url) // Logan.Li
|
||||
type Config struct {
|
||||
Protocol string // "openai" | "anthropic",由 client.go 工厂决定具体实现
|
||||
Endpoint string // 完整 base url,如 https://api.deepseek.com/v1
|
||||
APIKey string // Bearer token
|
||||
Model string // 模型名,如 deepseek-chat / gpt-4o-mini
|
||||
Temperature float64 // 采样温度
|
||||
MaxTokens int // 单次最大输出 token
|
||||
Timeout time.Duration // HTTP 超时(0 表示使用默认 180s)
|
||||
Protocol string // "openai" | "anthropic",由 client.go 工厂决定具体实现
|
||||
Endpoint string // 完整 base url,如 https://api.deepseek.com/v1
|
||||
APIKey string // Bearer token
|
||||
Model string // 模型名,如 deepseek-chat / gpt-4o-mini
|
||||
Temperature float64 // 采样温度
|
||||
MaxTokens int // 单次最大输出 token
|
||||
ReasoningEnabled bool // 是否启用推理/Plan 模式(OpenAI o-series / DeepSeek R1 等) // Logan.Li
|
||||
Timeout time.Duration // HTTP 超时(0 表示使用默认 180s)
|
||||
}
|
||||
|
||||
// OpenAIClient OpenAI 兼容客户端 // Logan.Li
|
||||
@@ -48,15 +49,45 @@ func NewOpenAIClient(cfg Config) Client {
|
||||
// Name 返回客户端名称 // Logan.Li
|
||||
func (c *OpenAIClient) Name() string { return "openai-compatible(" + c.cfg.Model + ")" }
|
||||
|
||||
// isOpenAIReasoningModel 判断是否为需要 reasoning_effort 的 OpenAI o-series 模型 // Logan.Li
|
||||
func isOpenAIReasoningModel(model string) bool {
|
||||
m := strings.ToLower(model)
|
||||
return strings.Contains(m, "o1") || strings.Contains(m, "o3")
|
||||
}
|
||||
|
||||
// isGenericReasoningModel 判断是否为通用推理开关模型(DeepSeek R1 等) // Logan.Li
|
||||
func isGenericReasoningModel(model string) bool {
|
||||
m := strings.ToLower(model)
|
||||
return strings.Contains(m, "reasoning") || strings.Contains(m, "r1") || strings.Contains(m, "deepseek-reasoner")
|
||||
}
|
||||
|
||||
// applyReasoningParams 根据模型类型应用 reasoning 参数 // Logan.Li
|
||||
func applyReasoningParams(payload *openaiRequest, enabled bool) {
|
||||
if !enabled {
|
||||
return
|
||||
}
|
||||
if isOpenAIReasoningModel(payload.Model) {
|
||||
// OpenAI o-series 使用 reasoning_effort,temperature 必须固定为 1(不传) // Logan.Li
|
||||
payload.ReasoningEffort = "medium"
|
||||
payload.Temperature = 0
|
||||
return
|
||||
}
|
||||
if isGenericReasoningModel(payload.Model) {
|
||||
payload.Reasoning = true
|
||||
}
|
||||
}
|
||||
|
||||
// openaiRequest OpenAI chat/completions 请求体 // Logan.Li
|
||||
type openaiRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Tools []Tool `json:"tools,omitempty"`
|
||||
ToolChoice string `json:"tool_choice,omitempty"` // "auto" 让模型自主决定
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"` // 流式补全(Stream 方法置 true)
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Tools []Tool `json:"tools,omitempty"`
|
||||
ToolChoice string `json:"tool_choice,omitempty"` // "auto" 让模型自主决定
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"` // 流式补全(Stream 方法置 true)
|
||||
ReasoningEffort string `json:"reasoning_effort,omitempty"` // OpenAI o-series 推理强度 // Logan.Li
|
||||
Reasoning bool `json:"reasoning,omitempty"` // DeepSeek 等部分平台推理开关 // Logan.Li
|
||||
}
|
||||
|
||||
// openaiResponse OpenAI chat/completions 响应体 // Logan.Li
|
||||
@@ -89,6 +120,7 @@ func (c *OpenAIClient) Complete(ctx context.Context, req *CompletionRequest) (*C
|
||||
if payload.MaxTokens <= 0 {
|
||||
payload.MaxTokens = 4096
|
||||
}
|
||||
applyReasoningParams(&payload, req.ReasoningEnabled || c.cfg.ReasoningEnabled)
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化请求失败: %w", err)
|
||||
@@ -151,6 +183,7 @@ func (c *OpenAIClient) Stream(ctx context.Context, req *CompletionRequest, onDel
|
||||
if payload.MaxTokens <= 0 {
|
||||
payload.MaxTokens = 4096
|
||||
}
|
||||
applyReasoningParams(&payload, req.ReasoningEnabled || c.cfg.ReasoningEnabled)
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化请求失败: %w", err)
|
||||
|
||||
@@ -43,10 +43,11 @@ type ToolFunction struct {
|
||||
|
||||
// CompletionRequest 补全请求参数 // Logan.Li
|
||||
type CompletionRequest struct {
|
||||
Messages []Message `json:"messages"`
|
||||
Tools []Tool `json:"tools,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Messages []Message `json:"messages"`
|
||||
Tools []Tool `json:"tools,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
ReasoningEnabled bool `json:"reasoning_enabled,omitempty"` // 是否启用推理/Plan 模式(协议相关) // Logan.Li
|
||||
}
|
||||
|
||||
// CompletionResponse 补全响应(取首个 choice) // Logan.Li
|
||||
|
||||
@@ -153,6 +153,17 @@ func StringParam(args map[string]interface{}, key string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Close 关闭所有实现了 Close() 的工具(如 ssh_execute 长连接) // Logan.Li
|
||||
func (r *Registry) Close() {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
for _, t := range r.tools {
|
||||
if c, ok := t.(interface{ Close() error }); ok {
|
||||
_ = c.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IntParam 取整型参数(兼容 int/float64/int64) // Logan.Li
|
||||
func IntParam(args map[string]interface{}, key string) int {
|
||||
if v, ok := args[key]; ok {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// ssh_execute.go - 远程命令执行工具 // Logan.Li
|
||||
// 复用 aiops 既有 pkg/remoteexec(Go 原生 SSH),凭据由 controller 从 model.Server 构建后注入。
|
||||
// 任务级复用 PersistentClient 长连接,避免每条命令重建 SSH 握手。
|
||||
// 实现 DynamicRiskTool:依据具体命令动态评估风险,决定是否触发用户确认。
|
||||
package tools
|
||||
|
||||
@@ -14,16 +15,28 @@ import (
|
||||
|
||||
// SSHExecuteTool 远程命令执行工具 // Logan.Li
|
||||
type SSHExecuteTool struct {
|
||||
cfg remoteexec.SSHConfig // 目标主机连接配置(含凭据)
|
||||
timeout time.Duration // 默认命令超时
|
||||
client *remoteexec.PersistentClient // 任务级 SSH 长连接
|
||||
timeout time.Duration // 默认命令超时
|
||||
}
|
||||
|
||||
// NewSSHExecuteTool 创建远程执行工具 // Logan.Li
|
||||
func NewSSHExecuteTool(cfg remoteexec.SSHConfig, timeout time.Duration) *SSHExecuteTool {
|
||||
// NewSSHExecuteTool 创建远程执行工具(内部建立 SSH 长连接) // Logan.Li
|
||||
func NewSSHExecuteTool(cfg remoteexec.SSHConfig, timeout time.Duration) (*SSHExecuteTool, error) {
|
||||
if timeout <= 0 {
|
||||
timeout = 60 * time.Second
|
||||
}
|
||||
return &SSHExecuteTool{cfg: cfg, timeout: timeout}
|
||||
client, err := remoteexec.NewPersistentClient(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SSH 长连接建立失败: %w", err)
|
||||
}
|
||||
return &SSHExecuteTool{client: client, timeout: timeout}, nil
|
||||
}
|
||||
|
||||
// Close 释放 SSH 长连接 // Logan.Li
|
||||
func (t *SSHExecuteTool) Close() error {
|
||||
if t.client == nil {
|
||||
return nil
|
||||
}
|
||||
return t.client.Close()
|
||||
}
|
||||
|
||||
// Name 工具名 // Logan.Li
|
||||
@@ -31,14 +44,9 @@ func (t *SSHExecuteTool) Name() string { return "ssh_execute" }
|
||||
|
||||
// Description 给 LLM 的工具说明 // Logan.Li
|
||||
func (t *SSHExecuteTool) Description() string {
|
||||
host := t.cfg.Host
|
||||
if host == "" {
|
||||
host = "目标主机"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"在远程服务器 %s(用户 %s)上执行 shell 命令并返回合并的 stdout+stderr 输出与退出码。"+
|
||||
"用于排障(查看日志/进程/磁盘/网络)、安装与配置软件、重启服务等。"+
|
||||
"高危命令(rm -rf /、mkfs、reboot 等)需用户确认。", host, t.cfg.User)
|
||||
return "在远程目标服务器上执行 shell 命令并返回合并的 stdout+stderr 输出与退出码。" +
|
||||
"用于排障(查看日志/进程/磁盘/网络)、安装与配置软件、重启服务等。" +
|
||||
"高危命令(rm -rf /、mkfs、reboot 等)需用户确认。"
|
||||
}
|
||||
|
||||
// Parameters JSON Schema 参数定义 // Logan.Li
|
||||
@@ -79,13 +87,11 @@ func (t *SSHExecuteTool) Execute(ctx context.Context, args map[string]interface{
|
||||
timeout = time.Duration(sec) * time.Second
|
||||
}
|
||||
|
||||
res, err := remoteexec.Exec(t.cfg, command, timeout)
|
||||
res, err := t.client.Exec(command, timeout)
|
||||
if err != nil {
|
||||
// 连接级失败(无法 SSH)
|
||||
return &Result{Success: false, Error: fmt.Sprintf("SSH 连接失败: %v", err)}, nil
|
||||
}
|
||||
|
||||
// 附带退出码,便于 agent 判断命令是否成功(排障关键信号)
|
||||
output := res.Output
|
||||
if !strings.HasSuffix(output, "\n") && output != "" {
|
||||
output += "\n"
|
||||
@@ -93,8 +99,8 @@ func (t *SSHExecuteTool) Execute(ctx context.Context, args map[string]interface{
|
||||
output += fmt.Sprintf("[exit_code=%d]", res.ExitCode)
|
||||
|
||||
return &Result{
|
||||
Success: res.Success, // exit_code==0
|
||||
Success: res.Success,
|
||||
Output: output,
|
||||
Error: res.Error,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,7 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&model.LogEntry{},
|
||||
&model.TopologyLayout{}, // 拓扑布局保存 // Logan.Li
|
||||
&model.TerminalSession{}, // 终端会话录制(终端审计) // Logan.Li
|
||||
&model.SessionCommand{}, // 终端会话命令审计 // Logan.Li
|
||||
&model.LLMConfig{}, // 大语言模型配置(AI 智能助手) // Logan.Li
|
||||
&model.ChatHistory{}, // 对话历史记录(按用户隔离) // Logan.Li
|
||||
&model.MetricsAggregate{}, // 指标时序聚合(Redis → MySQL)// Logan.Li
|
||||
|
||||
@@ -5,7 +5,6 @@ package remoteexec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
@@ -70,10 +69,7 @@ type UploadResult struct {
|
||||
// 返回合并的 stdout+stderr 输出、退出码和错误。
|
||||
func Exec(cfg SSHConfig, command string, timeout time.Duration) (*ExecResult, error) {
|
||||
start := time.Now()
|
||||
result := &ExecResult{
|
||||
Host: cfg.Host,
|
||||
Port: cfg.Port,
|
||||
}
|
||||
result := &ExecResult{Host: cfg.Host, Port: cfg.Port}
|
||||
|
||||
client, err := connect(cfg)
|
||||
if err != nil {
|
||||
@@ -83,70 +79,7 @@ func Exec(cfg SSHConfig, command string, timeout time.Duration) (*ExecResult, er
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// 创建 session
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
result.Duration = time.Since(start).Milliseconds()
|
||||
result.Error = fmt.Sprintf("创建SSH会话失败: %v", err)
|
||||
return result, err
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
// 设置超时
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
// 在 goroutine 中执行,支持超时
|
||||
type execOut struct {
|
||||
output []byte
|
||||
err error
|
||||
}
|
||||
ch := make(chan execOut, 1)
|
||||
|
||||
go func() {
|
||||
var stdout, stderr bytes.Buffer
|
||||
session.Stdout = &stdout
|
||||
session.Stderr = &stderr
|
||||
err := session.Run(command)
|
||||
|
||||
// 合并输出
|
||||
output := stdout.Bytes()
|
||||
if stderr.Len() > 0 {
|
||||
if len(output) > 0 {
|
||||
output = append(output, '\n')
|
||||
}
|
||||
output = append(output, stderr.Bytes()...)
|
||||
}
|
||||
ch <- execOut{output: output, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
result.Duration = time.Since(start).Milliseconds()
|
||||
result.Error = fmt.Sprintf("执行超时(%v)", timeout)
|
||||
result.ExitCode = -1
|
||||
return result, fmt.Errorf("exec timeout after %v", timeout)
|
||||
case out := <-ch:
|
||||
result.Duration = time.Since(start).Milliseconds()
|
||||
result.Output = string(out.output)
|
||||
|
||||
if out.err != nil {
|
||||
if exitErr, ok := out.err.(*ssh.ExitError); ok {
|
||||
result.ExitCode = exitErr.ExitStatus()
|
||||
result.Success = false
|
||||
} else {
|
||||
result.ExitCode = -1
|
||||
result.Error = out.err.Error()
|
||||
result.Success = false
|
||||
return result, out.err
|
||||
}
|
||||
} else {
|
||||
result.ExitCode = 0
|
||||
result.Success = true
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return execWithClient(client, cfg, command, timeout)
|
||||
}
|
||||
|
||||
// BatchExec 在多台远程主机上并发执行相同命令。
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// persistent_client.go - 任务级 SSH 长连接,多次 Exec 复用同一 client // Logan.Li
|
||||
package remoteexec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// PersistentClient 可复用的 SSH 长连接(任务生命周期内持有,Close 释放) // Logan.Li
|
||||
type PersistentClient struct {
|
||||
cfg SSHConfig
|
||||
client *ssh.Client
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewPersistentClient 建立 SSH 长连接 // Logan.Li
|
||||
func NewPersistentClient(cfg SSHConfig) (*PersistentClient, error) {
|
||||
client, err := connect(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PersistentClient{cfg: cfg, client: client}, nil
|
||||
}
|
||||
|
||||
// Exec 在长连接上执行命令;连接断开时自动重连一次 // Logan.Li
|
||||
func (p *PersistentClient) Exec(command string, timeout time.Duration) (*ExecResult, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
result, err := p.execLocked(command, timeout)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
// 连接级失败:重连后重试一次 // Logan.Li
|
||||
if p.client != nil {
|
||||
_ = p.client.Close()
|
||||
p.client = nil
|
||||
}
|
||||
client, connErr := connect(p.cfg)
|
||||
if connErr != nil {
|
||||
return result, err
|
||||
}
|
||||
p.client = client
|
||||
return p.execLocked(command, timeout)
|
||||
}
|
||||
|
||||
// execLocked 假定已持锁,在当前 client 上执行 // Logan.Li
|
||||
func (p *PersistentClient) execLocked(command string, timeout time.Duration) (*ExecResult, error) {
|
||||
if p.client == nil {
|
||||
client, err := connect(p.cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.client = client
|
||||
}
|
||||
return execWithClient(p.client, p.cfg, command, timeout)
|
||||
}
|
||||
|
||||
// Close 关闭长连接 // Logan.Li
|
||||
func (p *PersistentClient) Close() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.client == nil {
|
||||
return nil
|
||||
}
|
||||
err := p.client.Close()
|
||||
p.client = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// execWithClient 在已有 SSH client 上执行命令(供 Exec / PersistentClient 共用) // Logan.Li
|
||||
func execWithClient(client *ssh.Client, cfg SSHConfig, command string, timeout time.Duration) (*ExecResult, error) {
|
||||
start := time.Now()
|
||||
result := &ExecResult{Host: cfg.Host, Port: cfg.Port}
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
result.Duration = time.Since(start).Milliseconds()
|
||||
result.Error = fmt.Sprintf("创建SSH会话失败: %v", err)
|
||||
return result, err
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
if timeout <= 0 {
|
||||
timeout = 60 * time.Second
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
type execOut struct {
|
||||
output []byte
|
||||
err error
|
||||
}
|
||||
ch := make(chan execOut, 1)
|
||||
|
||||
go func() {
|
||||
var stdout, stderr bytes.Buffer
|
||||
session.Stdout = &stdout
|
||||
session.Stderr = &stderr
|
||||
runErr := session.Run(command)
|
||||
|
||||
output := stdout.Bytes()
|
||||
if stderr.Len() > 0 {
|
||||
if len(output) > 0 {
|
||||
output = append(output, '\n')
|
||||
}
|
||||
output = append(output, stderr.Bytes()...)
|
||||
}
|
||||
ch <- execOut{output: output, err: runErr}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
result.Duration = time.Since(start).Milliseconds()
|
||||
result.Error = fmt.Sprintf("执行超时(%v)", timeout)
|
||||
result.ExitCode = -1
|
||||
return result, fmt.Errorf("exec timeout after %v", timeout)
|
||||
case out := <-ch:
|
||||
result.Duration = time.Since(start).Milliseconds()
|
||||
result.Output = string(out.output)
|
||||
|
||||
if out.err != nil {
|
||||
if exitErr, ok := out.err.(*ssh.ExitError); ok {
|
||||
result.ExitCode = exitErr.ExitStatus()
|
||||
result.Success = false
|
||||
} else {
|
||||
result.ExitCode = -1
|
||||
result.Error = out.err.Error()
|
||||
result.Success = false
|
||||
return result, out.err
|
||||
}
|
||||
} else {
|
||||
result.ExitCode = 0
|
||||
result.Success = true
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -803,6 +803,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
ts.DELETE("/:id", terminalSessionHandler.DeleteTerminalSession)
|
||||
ts.POST("/:id/kill", terminalSessionHandler.KillSession)
|
||||
ts.GET("/:id/download", terminalSessionHandler.DownloadSession)
|
||||
ts.POST("/:id/replay-view", terminalSessionHandler.LogReplayView)
|
||||
ts.GET("/:id/stream", terminalSessionHandler.StreamSession)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user