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
|
||||
}
|
||||
Reference in new issue
Block a user