feat: 修复菜单路由+角色权限展示+操作审计+WebSSH终端审计
1. 菜单路由修复: /roles /users /logs-audit /terminal-audit 等系统管理子菜单 通过 useLocation 同步 URL 路径到 Tab 选中状态 2. WebSSH错误提示优化: Go error → 友好中文提示 i/o timeout → 连接超时(防火墙或网络不通) connection refused → 端口未开放/SSH服务未启动 cannot find session key → 密码或密钥认证失败 连接提示增加端口号显示 3. 角色权限展示: 31项权限种子数据 (资产管理/云资源/K8s/发布/监控/AIOps/系统管理) 4个角色权限分配: admin(31) ops(27) developer(16) viewer(3) 前端角色卡片显示权限名称而非原始code 4. 操作审计修复: 删除/创建/更新服务器均记录审计日志 批量删除逐条记录审计 5. WebSSH终端审计 (JumpServer模式): - 新增 TerminalSession 模型记录会话(用户/服务器/时间/状态) - webshell.go 录制终端输出(1MB上限) - 终端审计页面: 会话列表+状态筛选+批量删除 - 操作回放: xterm.js 重放录制的终端输出 - WS连接增加JWT鉴权+token传递
This commit is contained in:
1 parent
4797059be2
commit
bdc7a507c0
17 files changed
+985
-34
No files matched your search
+111
-1
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"ezops/internal/models"
|
"ezops/internal/models"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -8,7 +10,7 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// seedData 初始化种子数据(admin用户 + LDAP配置)
|
// seedData 初始化种子数据(admin用户 + LDAP配置 + 审批模板 + 权限角色)
|
||||||
func seedData(db *gorm.DB) {
|
func seedData(db *gorm.DB) {
|
||||||
// 1. 创建默认 admin 用户(仅首次)
|
// 1. 创建默认 admin 用户(仅首次)
|
||||||
var userCount int64
|
var userCount int64
|
||||||
@@ -110,4 +112,112 @@ func seedData(db *gorm.DB) {
|
|||||||
zap.L().Info("seeded default approval templates (server_apply + app_deploy)")
|
zap.L().Info("seeded default approval templates (server_apply + app_deploy)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. 创建默认权限和角色权限映射
|
||||||
|
var permCount int64
|
||||||
|
db.Model(&models.Permission{}).Count(&permCount)
|
||||||
|
if permCount == 0 {
|
||||||
|
perms := []models.Permission{
|
||||||
|
// 资产管理
|
||||||
|
{Name: "服务器管理", Code: "server:view", Type: "menu", Sort: 1, Status: 1},
|
||||||
|
{Name: "服务器创建", Code: "server:create", Type: "button", Sort: 2, Status: 1},
|
||||||
|
{Name: "服务器编辑", Code: "server:update", Type: "button", Sort: 3, Status: 1},
|
||||||
|
{Name: "服务器删除", Code: "server:delete", Type: "button", Sort: 4, Status: 1},
|
||||||
|
{Name: "WebShell终端", Code: "server:webshell", Type: "button", Sort: 5, Status: 1},
|
||||||
|
// 云资源
|
||||||
|
{Name: "云账号管理", Code: "cloud:account", Type: "menu", Sort: 10, Status: 1},
|
||||||
|
{Name: "域名管理", Code: "cloud:domain", Type: "menu", Sort: 11, Status: 1},
|
||||||
|
{Name: "云账单", Code: "cloud:billing", Type: "menu", Sort: 12, Status: 1},
|
||||||
|
// Kubernetes
|
||||||
|
{Name: "集群管理", Code: "k8s:cluster", Type: "menu", Sort: 20, Status: 1},
|
||||||
|
{Name: "节点管理", Code: "k8s:nodes", Type: "menu", Sort: 21, Status: 1},
|
||||||
|
{Name: "工作负载", Code: "k8s:workloads", Type: "menu", Sort: 22, Status: 1},
|
||||||
|
{Name: "架构拓扑", Code: "k8s:topology", Type: "menu", Sort: 23, Status: 1},
|
||||||
|
// 应用发布
|
||||||
|
{Name: "流水线", Code: "deploy:pipeline", Type: "menu", Sort: 30, Status: 1},
|
||||||
|
{Name: "应用发布", Code: "deploy:release", Type: "menu", Sort: 31, Status: 1},
|
||||||
|
// 监控告警
|
||||||
|
{Name: "告警中心", Code: "monitor:alert", Type: "menu", Sort: 40, Status: 1},
|
||||||
|
{Name: "告警规则", Code: "monitor:rule", Type: "button", Sort: 41, Status: 1},
|
||||||
|
{Name: "Agent监控", Code: "agent:monitor", Type: "menu", Sort: 42, Status: 1},
|
||||||
|
{Name: "Agent告警", Code: "agent:alert", Type: "menu", Sort: 43, Status: 1},
|
||||||
|
// 自动化运维
|
||||||
|
{Name: "定时任务", Code: "auto:cron", Type: "menu", Sort: 50, Status: 1},
|
||||||
|
{Name: "脚本管理", Code: "auto:script", Type: "menu", Sort: 51, Status: 1},
|
||||||
|
{Name: "Ansible", Code: "auto:ansible", Type: "menu", Sort: 52, Status: 1},
|
||||||
|
// AIOps
|
||||||
|
{Name: "智能助手", Code: "aiops:assistant", Type: "menu", Sort: 60, Status: 1},
|
||||||
|
{Name: "告警分析", Code: "aiops:analysis", Type: "menu", Sort: 61, Status: 1},
|
||||||
|
{Name: "容量预测", Code: "aiops:forecast", Type: "menu", Sort: 62, Status: 1},
|
||||||
|
// 资源申请
|
||||||
|
{Name: "资源申请", Code: "approval:apply", Type: "menu", Sort: 70, Status: 1},
|
||||||
|
{Name: "审批管理", Code: "approval:manage", Type: "button", Sort: 71, Status: 1},
|
||||||
|
// 系统管理
|
||||||
|
{Name: "用户管理", Code: "system:user", Type: "menu", Sort: 80, Status: 1},
|
||||||
|
{Name: "角色权限", Code: "system:role", Type: "menu", Sort: 81, Status: 1},
|
||||||
|
{Name: "操作审计", Code: "system:audit", Type: "menu", Sort: 82, Status: 1},
|
||||||
|
{Name: "LDAP配置", Code: "system:ldap", Type: "menu", Sort: 83, Status: 1},
|
||||||
|
// 仪表盘
|
||||||
|
{Name: "仪表盘", Code: "dashboard:view", Type: "menu", Sort: 90, Status: 1},
|
||||||
|
}
|
||||||
|
db.Create(&perms)
|
||||||
|
zap.L().Info("seeded default permissions")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 创建默认角色(如果不存在)
|
||||||
|
var roleCount int64
|
||||||
|
db.Model(&models.Role{}).Count(&roleCount)
|
||||||
|
if roleCount == 0 {
|
||||||
|
roles := []models.Role{
|
||||||
|
{Name: "管理员", Code: "admin", Description: "系统管理员,拥有全部权限", Status: 1},
|
||||||
|
{Name: "运维工程师", Code: "ops", Description: "日常运维管理", Status: 1},
|
||||||
|
{Name: "开发工程师", Code: "developer", Description: "开发人员,只读权限", Status: 1},
|
||||||
|
{Name: "只读用户", Code: "viewer", Description: "仅查看权限", Status: 1},
|
||||||
|
}
|
||||||
|
db.Create(&roles)
|
||||||
|
zap.L().Info("seeded default roles")
|
||||||
|
|
||||||
|
// 为各角色分配权限
|
||||||
|
var allPerms []models.Permission
|
||||||
|
db.Find(&allPerms)
|
||||||
|
for _, role := range roles {
|
||||||
|
var permIds []uint
|
||||||
|
switch role.Code {
|
||||||
|
case "admin":
|
||||||
|
for _, p := range allPerms {
|
||||||
|
permIds = append(permIds, p.ID)
|
||||||
|
}
|
||||||
|
case "ops":
|
||||||
|
for _, p := range allPerms {
|
||||||
|
if !strings.HasPrefix(p.Code, "system:") {
|
||||||
|
permIds = append(permIds, p.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "developer":
|
||||||
|
devPerms := map[string]bool{
|
||||||
|
"dashboard:view": true, "server:view": true, "cloud:account": true,
|
||||||
|
"cloud:domain": true, "cloud:billing": true,
|
||||||
|
"k8s:cluster": true, "k8s:nodes": true, "k8s:workloads": true, "k8s:topology": true,
|
||||||
|
"monitor:alert": true, "deploy:pipeline": true, "deploy:release": true,
|
||||||
|
"approval:apply": true, "aiops:assistant": true, "aiops:analysis": true, "aiops:forecast": true,
|
||||||
|
}
|
||||||
|
for _, p := range allPerms {
|
||||||
|
if devPerms[p.Code] {
|
||||||
|
permIds = append(permIds, p.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "viewer":
|
||||||
|
for _, p := range allPerms {
|
||||||
|
if p.Code == "dashboard:view" || p.Code == "server:view" || p.Code == "monitor:alert" {
|
||||||
|
permIds = append(permIds, p.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(permIds) > 0 {
|
||||||
|
var rolePerms []models.Permission
|
||||||
|
db.Where("id IN ?", permIds).Find(&rolePerms)
|
||||||
|
db.Model(&role).Association("Permissions").Append(rolePerms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,27 @@ func NewServerHandler(db *gorm.DB) *ServerHandler {
|
|||||||
return &ServerHandler{db: db}
|
return &ServerHandler{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recordServerAudit 记录服务器操作审计日志 // Logan.Li
|
||||||
|
func (h *ServerHandler) recordServerAudit(c *gin.Context, action, resource string, resourceID string, status int, errMsg string) {
|
||||||
|
userID, _ := c.Get("userID")
|
||||||
|
username, _ := c.Get("username")
|
||||||
|
uid, _ := userID.(uint)
|
||||||
|
uname, _ := username.(string)
|
||||||
|
|
||||||
|
h.db.Create(&models.AuditLog{
|
||||||
|
UserID: &uid,
|
||||||
|
Username: uname,
|
||||||
|
IP: c.ClientIP(),
|
||||||
|
UserAgent: c.Request.UserAgent(),
|
||||||
|
Module: "server",
|
||||||
|
Action: action,
|
||||||
|
Resource: resource,
|
||||||
|
ResourceID: resourceID,
|
||||||
|
Status: status,
|
||||||
|
Error: errMsg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ListServers 获取服务器列表
|
// ListServers 获取服务器列表
|
||||||
// @Summary 获取服务器列表
|
// @Summary 获取服务器列表
|
||||||
// @Description 获取所有服务器列表
|
// @Description 获取所有服务器列表
|
||||||
@@ -170,6 +191,8 @@ func (h *ServerHandler) CreateServer(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.recordServerAudit(c, "create", server.Hostname+" ("+server.IP+")", strconv.Itoa(int(server.ID)), 1, "")
|
||||||
|
|
||||||
c.JSON(http.StatusOK, models.Response{
|
c.JSON(http.StatusOK, models.Response{
|
||||||
Code: 200,
|
Code: 200,
|
||||||
Message: "创建成功",
|
Message: "创建成功",
|
||||||
@@ -268,6 +291,7 @@ func (h *ServerHandler) UpdateServer(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.db.Preload("Region").Preload("Cloud").First(&server, id)
|
h.db.Preload("Region").Preload("Cloud").First(&server, id)
|
||||||
|
h.recordServerAudit(c, "update", server.Hostname+" ("+server.IP+")", strconv.Itoa(int(server.ID)), 1, "")
|
||||||
|
|
||||||
c.JSON(http.StatusOK, models.Response{
|
c.JSON(http.StatusOK, models.Response{
|
||||||
Code: 200,
|
Code: 200,
|
||||||
@@ -288,6 +312,15 @@ func (h *ServerHandler) UpdateServer(c *gin.Context) {
|
|||||||
func (h *ServerHandler) DeleteServer(c *gin.Context) {
|
func (h *ServerHandler) DeleteServer(c *gin.Context) {
|
||||||
id, _ := strconv.Atoi(c.Param("id"))
|
id, _ := strconv.Atoi(c.Param("id"))
|
||||||
|
|
||||||
|
var server models.Server
|
||||||
|
if err := h.db.First(&server, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, models.Response{
|
||||||
|
Code: 404,
|
||||||
|
Message: "服务器不存在",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.db.Delete(&models.Server{}, id).Error; err != nil {
|
if err := h.db.Delete(&models.Server{}, id).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, models.Response{
|
c.JSON(http.StatusInternalServerError, models.Response{
|
||||||
Code: 500,
|
Code: 500,
|
||||||
@@ -296,6 +329,8 @@ func (h *ServerHandler) DeleteServer(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.recordServerAudit(c, "delete", server.Hostname+" ("+server.IP+")", strconv.Itoa(int(server.ID)), 1, "")
|
||||||
|
|
||||||
c.JSON(http.StatusOK, models.Response{
|
c.JSON(http.StatusOK, models.Response{
|
||||||
Code: 200,
|
Code: 200,
|
||||||
Message: "删除成功",
|
Message: "删除成功",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"ezops/internal/models"
|
"ezops/internal/models"
|
||||||
@@ -106,6 +107,10 @@ func (h *ServerHandler) BatchDeleteServers(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查询待删除服务器用于审计日志 // Logan.Li
|
||||||
|
var servers []models.Server
|
||||||
|
h.db.Where("id IN ?", req.IDs).Find(&servers)
|
||||||
|
|
||||||
if err := h.db.Delete(&models.Server{}, req.IDs).Error; err != nil {
|
if err := h.db.Delete(&models.Server{}, req.IDs).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, models.Response{
|
c.JSON(http.StatusInternalServerError, models.Response{
|
||||||
Code: 500,
|
Code: 500,
|
||||||
@@ -114,6 +119,11 @@ func (h *ServerHandler) BatchDeleteServers(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 记录批量删除审计日志
|
||||||
|
for _, s := range servers {
|
||||||
|
h.recordServerAudit(c, "delete", s.Hostname+" ("+s.IP+")", strconv.Itoa(int(s.ID)), 1, "")
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, models.Response{
|
c.JSON(http.StatusOK, models.Response{
|
||||||
Code: 200,
|
Code: 200,
|
||||||
Message: "删除成功",
|
Message: "删除成功",
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"ezops/internal/models"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TerminalSessionHandler 终端会话审计处理器 // Logan.Li
|
||||||
|
type TerminalSessionHandler struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTerminalSessionHandler 创建终端会话审计处理器
|
||||||
|
func NewTerminalSessionHandler(db *gorm.DB) *TerminalSessionHandler {
|
||||||
|
return &TerminalSessionHandler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTerminalSessions 终端会话列表
|
||||||
|
// GET /api/v1/terminal-sessions?server_id=&username=&status=&page=&page_size=
|
||||||
|
func (h *TerminalSessionHandler) ListTerminalSessions(c *gin.Context) {
|
||||||
|
var req models.TerminalSessionQuery
|
||||||
|
if err := c.ShouldBindQuery(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Page <= 0 {
|
||||||
|
req.Page = 1
|
||||||
|
}
|
||||||
|
if req.PageSize <= 0 {
|
||||||
|
req.PageSize = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
query := h.db.Model(&models.TerminalSession{})
|
||||||
|
if req.ServerID > 0 {
|
||||||
|
query = query.Where("server_id = ?", req.ServerID)
|
||||||
|
}
|
||||||
|
if req.Username != "" {
|
||||||
|
query = query.Where("username LIKE ?", "%"+req.Username+"%")
|
||||||
|
}
|
||||||
|
if req.Status != "" {
|
||||||
|
query = query.Where("status = ?", req.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "查询失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessions []models.TerminalSession
|
||||||
|
if err := query.Order("id DESC").
|
||||||
|
Offset((req.Page - 1) * req.PageSize).
|
||||||
|
Limit(req.PageSize).
|
||||||
|
Find(&sessions).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "查询失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{
|
||||||
|
List: sessions,
|
||||||
|
Total: total,
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTerminalSession 终端会话详情
|
||||||
|
// GET /api/v1/terminal-sessions/:id
|
||||||
|
func (h *TerminalSessionHandler) GetTerminalSession(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的会话ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var session models.TerminalSession
|
||||||
|
if err := h.db.First(&session, id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "会话不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "查询失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, models.Response{Code: 200, Data: session})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTerminalRecord 终端会话录制数据(用于回放)
|
||||||
|
// GET /api/v1/terminal-sessions/:id/record
|
||||||
|
func (h *TerminalSessionHandler) GetTerminalRecord(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的会话ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var session models.TerminalSession
|
||||||
|
if err := h.db.First(&session, id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "会话不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "查询失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, models.Response{Code: 200, Data: session.RecordData})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTerminalSession 删除终端会话记录
|
||||||
|
// DELETE /api/v1/terminal-sessions/:id
|
||||||
|
func (h *TerminalSessionHandler) DeleteTerminalSession(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的会话ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Delete(&models.TerminalSession{}, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchDeleteTerminalSessions 批量删除终端会话记录
|
||||||
|
// POST /api/v1/terminal-sessions/batch-delete
|
||||||
|
func (h *TerminalSessionHandler) BatchDeleteTerminalSessions(c *gin.Context) {
|
||||||
|
var req models.IDsRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的请求参数"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := h.db.Where("id IN ?", req.IDs).Delete(&models.TerminalSession{})
|
||||||
|
if result.Error != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功", Data: gin.H{"deleted": result.RowsAffected}})
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -43,7 +44,44 @@ func NewWebShellHandler(db *gorm.DB) *WebShellHandler {
|
|||||||
return &WebShellHandler{db: db}
|
return &WebShellHandler{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Terminal WebSocket终端连接 — 真实SSH // Logan.Li
|
// formatSSHError 将 Go SSH 错误转换为友好的中文提示 // Logan.Li
|
||||||
|
func formatSSHError(err error) string {
|
||||||
|
errStr := err.Error()
|
||||||
|
switch {
|
||||||
|
case contains(errStr, "i/o timeout"):
|
||||||
|
return "连接超时(目标主机可能在防火墙后或网络不通)"
|
||||||
|
case contains(errStr, "connection refused"):
|
||||||
|
return "连接被拒绝(SSH 服务未运行或端口错误)"
|
||||||
|
case contains(errStr, "no route to host"):
|
||||||
|
return "无法路由到目标主机(IP 地址不可达)"
|
||||||
|
case contains(errStr, "authentication failed") || contains(errStr, "unable to authenticate"):
|
||||||
|
return "认证失败(用户名或密码错误)"
|
||||||
|
case contains(errStr, "handshake"):
|
||||||
|
return "SSH 握手失败(目标可能不是 SSH 服务)"
|
||||||
|
case contains(errStr, "dns") || contains(errStr, "no such host"):
|
||||||
|
return "域名解析失败(IP 地址无效)"
|
||||||
|
case contains(errStr, "ssh: must specify"):
|
||||||
|
return "认证方式错误(未配置密码或密钥)"
|
||||||
|
default:
|
||||||
|
return errStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s, substr string) bool {
|
||||||
|
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
|
||||||
|
(len(s) > 0 && len(substr) > 0 && indexOf(s, substr) >= 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(s, substr string) int {
|
||||||
|
for i := 0; i <= len(s)-len(substr); i++ {
|
||||||
|
if s[i:i+len(substr)] == substr {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal WebSocket终端连接 — 真实SSH,带终端会话录制 // Logan.Li
|
||||||
func (h *WebShellHandler) Terminal(c *gin.Context) {
|
func (h *WebShellHandler) Terminal(c *gin.Context) {
|
||||||
serverID := c.Query("id")
|
serverID := c.Query("id")
|
||||||
if serverID == "" {
|
if serverID == "" {
|
||||||
@@ -65,9 +103,51 @@ func (h *WebShellHandler) Terminal(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
|
// 创建终端会话记录(终端审计) // Logan.Li
|
||||||
|
var userIDPtr *uint
|
||||||
|
if uid, exists := c.Get("userID"); exists {
|
||||||
|
if uidVal, ok := uid.(uint); ok {
|
||||||
|
userIDPtr = &uidVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
username, _ := c.Get("username")
|
||||||
|
usernameStr, _ := username.(string)
|
||||||
|
|
||||||
|
termSession := models.TerminalSession{
|
||||||
|
UserID: userIDPtr,
|
||||||
|
Username: usernameStr,
|
||||||
|
ServerID: server.ID,
|
||||||
|
ServerName: server.Hostname,
|
||||||
|
ServerIP: server.IP,
|
||||||
|
ServerPort: server.Port,
|
||||||
|
ClientIP: c.ClientIP(),
|
||||||
|
Status: "active",
|
||||||
|
StartTime: time.Now(),
|
||||||
|
}
|
||||||
|
if err := h.db.Create(&termSession).Error; err != nil {
|
||||||
|
log.Printf("[WebShell] 创建会话记录失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 终端录制缓冲(限制 1MB 避免内存问题) // Logan.Li
|
||||||
|
const maxRecordSize = 1024 * 1024
|
||||||
|
var recordBuf strings.Builder
|
||||||
|
|
||||||
|
// finalizeSession 结束会话记录(统一收尾)
|
||||||
|
finalizeSession := func(status string) {
|
||||||
|
endTime := time.Now()
|
||||||
|
duration := int(endTime.Sub(termSession.StartTime).Seconds())
|
||||||
|
h.db.Model(&termSession).Updates(map[string]interface{}{
|
||||||
|
"status": status,
|
||||||
|
"duration": duration,
|
||||||
|
"end_time": &endTime,
|
||||||
|
"record_data": recordBuf.String(),
|
||||||
|
"record_size": recordBuf.Len(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
conn.WriteJSON(WSMessage{
|
conn.WriteJSON(WSMessage{
|
||||||
Status: 0,
|
Status: 0,
|
||||||
Data: "\r\n\x1b[36m正在连接 " + server.Hostname + " (" + server.IP + ")...\x1b[0m\r\n",
|
Data: "\r\n\x1b[36m正在连接 " + server.Hostname + " (" + server.IP + ":" + strconv.Itoa(server.Port) + ")...\x1b[0m\r\n",
|
||||||
})
|
})
|
||||||
|
|
||||||
// 构建 SSH 配置
|
// 构建 SSH 配置
|
||||||
@@ -96,10 +176,16 @@ func (h *WebShellHandler) Terminal(c *gin.Context) {
|
|||||||
sshClient, err := ssh.Dial("tcp", addr, sshConfig)
|
sshClient, err := ssh.Dial("tcp", addr, sshConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[WebShell] SSH连接失败 %s: %v", addr, err)
|
log.Printf("[WebShell] SSH连接失败 %s: %v", addr, err)
|
||||||
conn.WriteJSON(WSMessage{
|
friendlyErr := formatSSHError(err)
|
||||||
Status: 0,
|
errMsg := "\r\n\x1b[31m连接失败: " + friendlyErr + "\x1b[0m\r\n" +
|
||||||
Data: "\r\n\x1b[31mSSH连接失败: " + err.Error() + "\x1b[0m\r\n\x1b[33m提示: 请检查服务器IP/端口/密码是否正确,目标主机是否在线\x1b[0m\r\n",
|
"\x1b[33m──────────────────────────\x1b[0m\r\n" +
|
||||||
})
|
"\x1b[33m目标:\x1b[0m " + server.Hostname + " (" + server.IP + ":" + strconv.Itoa(server.Port) + ")\r\n" +
|
||||||
|
"\x1b[33m账号:\x1b[0m " + server.Username + "\r\n" +
|
||||||
|
"\x1b[33m排查:\x1b[0m ① 检查 IP/端口是否正确 ② 确认 SSH 服务运行中 ③ 验证账号密码\r\n" +
|
||||||
|
"\x1b[33m──────────────────────────\x1b[0m\r\n"
|
||||||
|
conn.WriteJSON(WSMessage{Status: 0, Data: errMsg})
|
||||||
|
recordBuf.WriteString(errMsg)
|
||||||
|
finalizeSession("failed")
|
||||||
conn.Close()
|
conn.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -112,6 +198,7 @@ func (h *WebShellHandler) Terminal(c *gin.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[WebShell] 创建session失败: %v", err)
|
log.Printf("[WebShell] 创建session失败: %v", err)
|
||||||
conn.WriteJSON(WSMessage{Status: 0, Data: "\r\n\x1b[31m创建SSH会话失败\x1b[0m\r\n"})
|
conn.WriteJSON(WSMessage{Status: 0, Data: "\r\n\x1b[31m创建SSH会话失败\x1b[0m\r\n"})
|
||||||
|
finalizeSession("failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer session.Close()
|
defer session.Close()
|
||||||
@@ -125,45 +212,62 @@ func (h *WebShellHandler) Terminal(c *gin.Context) {
|
|||||||
if err := session.RequestPty("xterm-256color", 24, 80, modes); err != nil {
|
if err := session.RequestPty("xterm-256color", 24, 80, modes); err != nil {
|
||||||
log.Printf("[WebShell] PTY请求失败: %v", err)
|
log.Printf("[WebShell] PTY请求失败: %v", err)
|
||||||
conn.WriteJSON(WSMessage{Status: 0, Data: "\r\n\x1b[31mPTY请求失败\x1b[0m\r\n"})
|
conn.WriteJSON(WSMessage{Status: 0, Data: "\r\n\x1b[31mPTY请求失败\x1b[0m\r\n"})
|
||||||
|
finalizeSession("failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取管道
|
// 获取管道
|
||||||
stdinPipe, err := session.StdinPipe()
|
stdinPipe, err := session.StdinPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
finalizeSession("failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
stdoutPipe, err := session.StdoutPipe()
|
stdoutPipe, err := session.StdoutPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
finalizeSession("failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动 shell
|
// 启动 shell
|
||||||
if err := session.Shell(); err != nil {
|
if err := session.Shell(); err != nil {
|
||||||
log.Printf("[WebShell] Shell启动失败: %v", err)
|
log.Printf("[WebShell] Shell启动失败: %v", err)
|
||||||
|
finalizeSession("failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送欢迎消息
|
// 发送欢迎消息
|
||||||
|
welcome := "\r\n\x1b[32mEZOps WebShell\x1b[0m — 已连接 " + server.Hostname + " (" + server.IP + ")\r\n\r\n"
|
||||||
conn.WriteJSON(WSMessage{
|
conn.WriteJSON(WSMessage{
|
||||||
Status: 0,
|
Status: 0,
|
||||||
Data: "\r\n\x1b[32mEZOps WebShell\x1b[0m — 已连接 " + server.Hostname + " (" + server.IP + ")\r\n\r\n",
|
Data: welcome,
|
||||||
})
|
})
|
||||||
|
recordBuf.WriteString(welcome)
|
||||||
|
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
|
|
||||||
// goroutine: SSH stdout -> WebSocket
|
// goroutine: SSH stdout -> WebSocket(同时录制输出) // Logan.Li
|
||||||
go func() {
|
go func() {
|
||||||
defer close(done)
|
defer close(done)
|
||||||
buf := make([]byte, 8192)
|
buf := make([]byte, 8192)
|
||||||
for {
|
for {
|
||||||
n, err := stdoutPipe.Read(buf)
|
n, err := stdoutPipe.Read(buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
conn.WriteJSON(WSMessage{Status: 0, Data: "\r\n\x1b[31m[远程连接已断开]\x1b[0m\r\n"})
|
discMsg := "\r\n\x1b[31m[远程连接已断开]\x1b[0m\r\n"
|
||||||
|
conn.WriteJSON(WSMessage{Status: 0, Data: discMsg})
|
||||||
|
recordBuf.WriteString(discMsg)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
conn.WriteJSON(WSMessage{Status: 0, Data: string(buf[:n])})
|
conn.WriteJSON(WSMessage{Status: 0, Data: string(buf[:n])})
|
||||||
|
// 录制终端输出(受 1MB 上限保护)
|
||||||
|
if recordBuf.Len() < maxRecordSize {
|
||||||
|
remain := maxRecordSize - recordBuf.Len()
|
||||||
|
if n > remain {
|
||||||
|
recordBuf.Write(buf[:remain])
|
||||||
|
} else {
|
||||||
|
recordBuf.Write(buf[:n])
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -195,5 +299,6 @@ func (h *WebShellHandler) Terminal(c *gin.Context) {
|
|||||||
// 等待 stdout goroutine 结束
|
// 等待 stdout goroutine 结束
|
||||||
<-done
|
<-done
|
||||||
stdinPipe.Close()
|
stdinPipe.Close()
|
||||||
|
finalizeSession("closed")
|
||||||
log.Printf("[WebShell] 断开 %s (%s)", server.Hostname, addr)
|
log.Printf("[WebShell] 断开 %s (%s)", server.Hostname, addr)
|
||||||
}
|
}
|
||||||
@@ -39,9 +39,9 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// WebSocket终端 (token via query param)
|
// WebSocket终端 (JWT 鉴权,支持 ?token= 查询参数;终端会话录制) // Logan.Li
|
||||||
webShellHandler := handlers.NewWebShellHandler(db)
|
webShellHandler := handlers.NewWebShellHandler(db)
|
||||||
r.GET("/ws/terminal", webShellHandler.Terminal)
|
r.GET("/ws/terminal", middleware.JWTAuth(&cfg.JWT), webShellHandler.Terminal)
|
||||||
|
|
||||||
// 公开路由
|
// 公开路由
|
||||||
authHandler := handlers.NewUserHandler(db, cfg)
|
authHandler := handlers.NewUserHandler(db, cfg)
|
||||||
@@ -371,6 +371,17 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) {
|
|||||||
audit.POST("/logs/clear", auditHandler.ClearAuditLogs)
|
audit.POST("/logs/clear", auditHandler.ClearAuditLogs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 终端会话审计路由(WebSSH 录制回放) // Logan.Li
|
||||||
|
terminalSessionHandler := handlers.NewTerminalSessionHandler(db)
|
||||||
|
ts := authorized.Group("/terminal-sessions")
|
||||||
|
{
|
||||||
|
ts.GET("", terminalSessionHandler.ListTerminalSessions)
|
||||||
|
ts.POST("/batch-delete", terminalSessionHandler.BatchDeleteTerminalSessions)
|
||||||
|
ts.GET("/:id", terminalSessionHandler.GetTerminalSession)
|
||||||
|
ts.GET("/:id/record", terminalSessionHandler.GetTerminalRecord)
|
||||||
|
ts.DELETE("/:id", terminalSessionHandler.DeleteTerminalSession)
|
||||||
|
}
|
||||||
|
|
||||||
// Agent 上报路由(无需 JWT)
|
// Agent 上报路由(无需 JWT)
|
||||||
agentHandler := handlers.NewAgentHandler(db)
|
agentHandler := handlers.NewAgentHandler(db)
|
||||||
taskHandler := handlers.NewAgentTaskHandler(db)
|
taskHandler := handlers.NewAgentTaskHandler(db)
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// TerminalSession SSH终端会话记录(JumpServer 式终端审计) // Logan.Li
|
||||||
|
type TerminalSession struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
UserID *uint `json:"user_id"`
|
||||||
|
Username string `gorm:"size:64" json:"username"`
|
||||||
|
ServerID uint `json:"server_id"`
|
||||||
|
ServerName string `gorm:"size:128" json:"server_name"`
|
||||||
|
ServerIP string `gorm:"size:64" json:"server_ip"`
|
||||||
|
ServerPort int `json:"server_port"`
|
||||||
|
ClientIP string `gorm:"size:64" json:"client_ip"`
|
||||||
|
Status string `gorm:"size:32" json:"status"` // active, closed, failed
|
||||||
|
Duration int `json:"duration"` // seconds
|
||||||
|
RecordData string `gorm:"type:text" json:"-"` // 终端录制原始输出(不直接返回 JSON)
|
||||||
|
RecordSize int `json:"record_size"` // 录制字节数
|
||||||
|
StartTime time.Time `json:"start_time"`
|
||||||
|
EndTime *time.Time `json:"end_time"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 表名
|
||||||
|
func (TerminalSession) TableName() string {
|
||||||
|
return "terminal_sessions"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TerminalSessionQuery 终端会话查询
|
||||||
|
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"`
|
||||||
|
}
|
||||||
@@ -100,6 +100,7 @@ func AutoMigrate(db *gorm.DB) error {
|
|||||||
&models.AnsibleInventory{},
|
&models.AnsibleInventory{},
|
||||||
&models.LogSource{},
|
&models.LogSource{},
|
||||||
&models.LogEntry{},
|
&models.LogEntry{},
|
||||||
&models.TopologyLayout{}, // 拓扑布局保存 // Logan.Li
|
&models.TopologyLayout{}, // 拓扑布局保存 // Logan.Li
|
||||||
|
&models.TerminalSession{}, // 终端会话录制(终端审计) // Logan.Li
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -88,6 +88,7 @@ function App() {
|
|||||||
<Route path="users" element={<SystemManager />} />
|
<Route path="users" element={<SystemManager />} />
|
||||||
<Route path="roles" element={<SystemManager />} />
|
<Route path="roles" element={<SystemManager />} />
|
||||||
<Route path="logs-audit" element={<SystemManager />} />
|
<Route path="logs-audit" element={<SystemManager />} />
|
||||||
|
<Route path="terminal-audit" element={<SystemManager />} />
|
||||||
<Route path="settings" element={<SystemManager />} />
|
<Route path="settings" element={<SystemManager />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ const menuItems: MenuItem[] = [
|
|||||||
{ id: 'users', name: '用户管理', icon: Users, path: '/users' },
|
{ id: 'users', name: '用户管理', icon: Users, path: '/users' },
|
||||||
{ id: 'roles', name: '角色权限', icon: Shield, path: '/roles' },
|
{ id: 'roles', name: '角色权限', icon: Shield, path: '/roles' },
|
||||||
{ id: 'logs-audit', name: '操作审计', icon: FileCode, path: '/logs-audit' },
|
{ id: 'logs-audit', name: '操作审计', icon: FileCode, path: '/logs-audit' },
|
||||||
|
{ id: 'terminal-audit', name: '终端审计', icon: Terminal, path: '/terminal-audit' },
|
||||||
{ id: 'settings', name: '系统设置', icon: Settings, path: '/settings' },
|
{ id: 'settings', name: '系统设置', icon: Settings, path: '/settings' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { FitAddon } from 'xterm-addon-fit';
|
|||||||
import 'xterm/css/xterm.css';
|
import 'xterm/css/xterm.css';
|
||||||
import { Maximize2, Minimize2, X } from 'lucide-react';
|
import { Maximize2, Minimize2, X } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useUserStore } from '@/store';
|
||||||
|
|
||||||
interface TerminalDialogProps {
|
interface TerminalDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -61,7 +62,8 @@ export function TerminalDialog({
|
|||||||
|
|
||||||
// WebSocket 连接
|
// WebSocket 连接
|
||||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
const wsUrl = `${proto}://${location.host}/ws/terminal?id=${serverId}&cols=80&rows=24`;
|
const token = useUserStore.getState().token || '';
|
||||||
|
const wsUrl = `${proto}://${location.host}/ws/terminal?id=${serverId}&cols=80&rows=24&token=${encodeURIComponent(token)}`;
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { Terminal as XTerm } from 'xterm';
|
|||||||
import { FitAddon } from 'xterm-addon-fit';
|
import { FitAddon } from 'xterm-addon-fit';
|
||||||
import 'xterm/css/xterm.css';
|
import 'xterm/css/xterm.css';
|
||||||
import { serverApi } from '@/services/api';
|
import { serverApi } from '@/services/api';
|
||||||
|
import { useUserStore } from '@/store';
|
||||||
|
|
||||||
interface TerminalSession {
|
interface TerminalSession {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -116,7 +117,8 @@ export function WebShell() {
|
|||||||
term.loadAddon(fit);
|
term.loadAddon(fit);
|
||||||
|
|
||||||
const proto = typeof location !== 'undefined' && location.protocol === 'https:' ? 'wss' : 'ws';
|
const proto = typeof location !== 'undefined' && location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
const wsUrl = `${proto}://${location.host}/ws/terminal?id=${server.id}&cols=${cols}&rows=${rows}`;
|
const token = useUserStore.getState().token || '';
|
||||||
|
const wsUrl = `${proto}://${location.host}/ws/terminal?id=${server.id}&cols=${cols}&rows=${rows}&token=${encodeURIComponent(token)}`;
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
// 终端输入 -> WebSocket
|
// 终端输入 -> WebSocket
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Terminal as XTerm } from 'xterm';
|
||||||
|
import { FitAddon } from 'xterm-addon-fit';
|
||||||
|
import 'xterm/css/xterm.css';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { terminalSessionApi } from '@/services/api';
|
||||||
|
|
||||||
|
interface ReplayDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
sessionId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 终端录制回放 Dialog — 用 xterm.js 重放录制的终端输出 // Logan.Li
|
||||||
|
*/
|
||||||
|
export function ReplayDialog({ open, onOpenChange, sessionId }: ReplayDialogProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const termRef = useRef<XTerm | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !sessionId) return;
|
||||||
|
|
||||||
|
const term = new XTerm({
|
||||||
|
cursorBlink: false,
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||||
|
disableStdin: true,
|
||||||
|
allowProposedApi: true,
|
||||||
|
theme: {
|
||||||
|
background: '#0a0e14',
|
||||||
|
foreground: '#d6d6d6',
|
||||||
|
cursor: '#0a0e14',
|
||||||
|
selectionBackground: 'rgba(255,255,255,0.2)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fit = new FitAddon();
|
||||||
|
term.loadAddon(fit);
|
||||||
|
termRef.current = term;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
terminalSessionApi
|
||||||
|
.getRecord(sessionId)
|
||||||
|
.then((data: any) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (containerRef.current) {
|
||||||
|
term.open(containerRef.current);
|
||||||
|
try {
|
||||||
|
fit.fit();
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
const output = typeof data === 'string' ? data : '';
|
||||||
|
term.write(output);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled && containerRef.current) {
|
||||||
|
term.open(containerRef.current);
|
||||||
|
term.write('\x1b[31m无录制数据\x1b[0m\r\n');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
try {
|
||||||
|
term.dispose();
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
termRef.current = null;
|
||||||
|
};
|
||||||
|
}, [open, sessionId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="bg-slate-900 border-slate-800 p-0 gap-0" style={{ maxWidth: '900px', width: '90vw' }}>
|
||||||
|
<DialogHeader className="px-4 py-3 border-b border-slate-800">
|
||||||
|
<DialogTitle className="text-slate-100 text-base">终端录制回放 #{sessionId ?? ''}</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-500 sr-only">重放该会话录制的终端输出</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="relative bg-[#0a0e14]" style={{ height: '460px' }}>
|
||||||
|
{loading && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-blue-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={containerRef} className="absolute inset-0 p-1" />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { CheckCircle, XCircle, Users, Shield, FileCode, Settings } from 'lucide-react';
|
import { CheckCircle, XCircle, Users, Shield, FileCode, Settings, TerminalSquare } from 'lucide-react';
|
||||||
import { userApi } from '@/services/api';
|
import { userApi } from '@/services/api';
|
||||||
import type { Role } from '@/types';
|
import type { Role } from '@/types';
|
||||||
import { SystemUserTab } from './SystemUserTab';
|
import { SystemUserTab } from './SystemUserTab';
|
||||||
import { SystemRolesTab } from './SystemRolesTab';
|
import { SystemRolesTab } from './SystemRolesTab';
|
||||||
import { SystemLogsTab } from './SystemLogsTab';
|
import { SystemLogsTab } from './SystemLogsTab';
|
||||||
import { SystemSettingsTab } from './SystemSettingsTab';
|
import { SystemSettingsTab } from './SystemSettingsTab';
|
||||||
|
import { TerminalSessionsTab } from './TerminalSessionsTab';
|
||||||
import { useLdapManagement } from './useLdapManagement';
|
import { useLdapManagement } from './useLdapManagement';
|
||||||
import type { UserFormData } from './UserFormDialog';
|
import type { UserFormData } from './UserFormDialog';
|
||||||
|
|
||||||
@@ -36,7 +38,24 @@ const emptyForm: UserFormData = {
|
|||||||
|
|
||||||
export function SystemManager() {
|
export function SystemManager() {
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const [activeTab, setActiveTab] = useState('users');
|
const location = useLocation();
|
||||||
|
// 侧边栏菜单路径映射到 Tab // Logan.Li
|
||||||
|
const pathToTab: Record<string, string> = {
|
||||||
|
'/users': 'users',
|
||||||
|
'/roles': 'roles',
|
||||||
|
'/logs-audit': 'logs',
|
||||||
|
'/terminal-audit': 'terminal',
|
||||||
|
'/settings': 'settings',
|
||||||
|
};
|
||||||
|
const [activeTab, setActiveTab] = useState(pathToTab[location.pathname] || 'users');
|
||||||
|
|
||||||
|
// URL 变化时同步 Tab(侧边栏菜单点击触发) // Logan.Li
|
||||||
|
useEffect(() => {
|
||||||
|
const tab = pathToTab[location.pathname];
|
||||||
|
if (tab && tab !== activeTab) {
|
||||||
|
setActiveTab(tab);
|
||||||
|
}
|
||||||
|
}, [location.pathname]);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [users, setUsers] = useState<any[]>([]);
|
const [users, setUsers] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -238,6 +257,10 @@ export function SystemManager() {
|
|||||||
<FileCode className="h-4 w-4 mr-2" />
|
<FileCode className="h-4 w-4 mr-2" />
|
||||||
操作审计
|
操作审计
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="terminal" className="data-[state=active]:bg-slate-700">
|
||||||
|
<TerminalSquare className="h-4 w-4 mr-2" />
|
||||||
|
终端审计
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="settings" className="data-[state=active]:bg-slate-700">
|
<TabsTrigger value="settings" className="data-[state=active]:bg-slate-700">
|
||||||
<Settings className="h-4 w-4 mr-2" />
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
系统设置
|
系统设置
|
||||||
@@ -299,6 +322,10 @@ export function SystemManager() {
|
|||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="terminal" className="mt-4 space-y-4">
|
||||||
|
<TerminalSessionsTab />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="settings" className="mt-4">
|
<TabsContent value="settings" className="mt-4">
|
||||||
<SystemSettingsTab {...ldap.settingsTabProps} />
|
<SystemSettingsTab {...ldap.settingsTabProps} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -34,14 +34,15 @@ function normalizeRole(raw: any): Role {
|
|||||||
name: raw.name ?? '',
|
name: raw.name ?? '',
|
||||||
code: raw.code ?? '',
|
code: raw.code ?? '',
|
||||||
description: raw.description ?? '',
|
description: raw.description ?? '',
|
||||||
// 后端可能返回权限对象数组或字符串数组,统一映射为编码字符串数组
|
// 后端返回权限对象数组(含 name/code/type),映射为显示名数组
|
||||||
permissions: rawPerms.map((p: any) => (typeof p === 'string' ? p : p?.code ?? '')).filter(Boolean),
|
permissions: rawPerms.map((p: any) => (typeof p === 'string' ? p : p?.name ?? p?.code ?? '')).filter(Boolean),
|
||||||
|
permissionCodes: rawPerms.map((p: any) => (typeof p === 'string' ? p : p?.code ?? '')).filter(Boolean),
|
||||||
createdAt: raw.createdAt ?? raw.created_at ?? '',
|
createdAt: raw.createdAt ?? raw.created_at ?? '',
|
||||||
// 保留原始权限 ID 供编辑使用
|
// 保留原始权限 ID 供编辑使用
|
||||||
_permissionIds: Array.isArray(raw.permissions)
|
_permissionIds: rawPerms
|
||||||
? raw.permissions.map((p: any) => (typeof p === 'object' ? Number(p.id) : 0)).filter(Boolean)
|
.map((p: any) => (typeof p === 'object' ? Number(p.id) : 0))
|
||||||
: [],
|
.filter(Boolean),
|
||||||
} as Role & { _permissionIds: number[] };
|
} as Role & { _permissionIds: number[]; permissionCodes: string[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyForm: RoleFormData = {
|
const emptyForm: RoleFormData = {
|
||||||
@@ -185,7 +186,7 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
{roles.map((role) => (
|
{roles.map((role: any) => (
|
||||||
<Card key={role.id} className="bg-slate-900 border-slate-800">
|
<Card key={role.id} className="bg-slate-900 border-slate-800">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
@@ -228,21 +229,25 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm text-slate-400 mb-3">{role.description || '暂无描述'}</p>
|
<p className="text-sm text-slate-400 mb-3">{role.description || '暂无描述'}</p>
|
||||||
|
{/* 权限按模块分组显示 // Logan.Li */}
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{role.permissions.slice(0, 5).map((permission) => (
|
{(role.permissions as string[]).slice(0, 8).map((permission, idx) => (
|
||||||
<Badge key={permission} variant="outline" className="border-slate-700 text-slate-400 text-xs">
|
<Badge key={idx} variant="outline" className="border-slate-700 text-slate-400 text-xs">
|
||||||
{permission}
|
{permission}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
{role.permissions.length > 5 && (
|
{(role.permissions as string[]).length > 8 && (
|
||||||
<Badge variant="outline" className="border-slate-700 text-slate-400 text-xs">
|
<Badge variant="outline" className="border-blue-500/30 text-blue-400 text-xs">
|
||||||
+{role.permissions.length - 5}
|
+{(role.permissions as string[]).length - 8} 项
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{role.permissions.length === 0 && (
|
{(role.permissions as string[]).length === 0 && (
|
||||||
<span className="text-xs text-slate-600">未分配权限</span>
|
<span className="text-xs text-slate-600">未分配权限</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-slate-600">
|
||||||
|
共 {role.permissions?.length || 0} 项权限
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -294,11 +299,11 @@ function RoleDetailDialog({
|
|||||||
<p className="text-sm text-slate-200">{role?.description || '暂无描述'}</p>
|
<p className="text-sm text-slate-200">{role?.description || '暂无描述'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium text-slate-300 mb-2">权限列表</h4>
|
<h4 className="text-sm font-medium text-slate-300 mb-2">权限列表 ({role?.permissions?.length || 0} 项)</h4>
|
||||||
{role && role.permissions.length > 0 ? (
|
{role && role.permissions.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2 max-h-60 overflow-y-auto">
|
||||||
{role.permissions.map((p) => (
|
{role.permissions.map((p: string, idx: number) => (
|
||||||
<Badge key={p} variant="outline" className="border-slate-700 text-slate-400">
|
<Badge key={idx} variant="outline" className="border-slate-700 text-slate-300">
|
||||||
{p}
|
{p}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { Search, RefreshCw, Trash2, Play } from 'lucide-react';
|
||||||
|
import { terminalSessionApi } from '@/services/api';
|
||||||
|
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||||
|
import { ReplayDialog } from './ReplayDialog';
|
||||||
|
|
||||||
|
interface TerminalSession {
|
||||||
|
id: number;
|
||||||
|
user_id?: number;
|
||||||
|
username: string;
|
||||||
|
server_id: number;
|
||||||
|
server_name: string;
|
||||||
|
server_ip: string;
|
||||||
|
server_port: number;
|
||||||
|
client_ip: string;
|
||||||
|
status: string;
|
||||||
|
duration: number;
|
||||||
|
record_size: number;
|
||||||
|
start_time: string;
|
||||||
|
end_time?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(sec: number): string {
|
||||||
|
if (!sec || sec < 0) return '--';
|
||||||
|
if (sec < 60) return `${sec}秒`;
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = sec % 60;
|
||||||
|
if (m < 60) return `${m}分${s > 0 ? `${s}秒` : ''}`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
const rm = m % 60;
|
||||||
|
return `${h}时${rm > 0 ? `${rm}分` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (!bytes) return '0 B';
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 终端审计 Tab — WebSSH 会话录制列表与回放(JumpServer 式审计) // Logan.Li
|
||||||
|
*/
|
||||||
|
export function TerminalSessionsTab() {
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const [sessions, setSessions] = useState<TerminalSession[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
|
const [replayId, setReplayId] = useState<number | null>(null);
|
||||||
|
const [replayOpen, setReplayOpen] = useState(false);
|
||||||
|
|
||||||
|
const fetchSessions = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const resp = await terminalSessionApi.list({ page: 1, page_size: 100 });
|
||||||
|
setSessions(resp?.list ?? []);
|
||||||
|
} catch {
|
||||||
|
setSessions([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSessions();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filtered = sessions.filter((s) => {
|
||||||
|
const kw = keyword.toLowerCase();
|
||||||
|
const kwMatch =
|
||||||
|
!kw ||
|
||||||
|
(s.username || '').toLowerCase().includes(kw) ||
|
||||||
|
(s.server_name || '').toLowerCase().includes(kw) ||
|
||||||
|
(s.server_ip || '').toLowerCase().includes(kw) ||
|
||||||
|
(s.client_ip || '').toLowerCase().includes(kw);
|
||||||
|
const stMatch = !statusFilter || s.status === statusFilter;
|
||||||
|
return kwMatch && stMatch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
if (status === 'active') {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
|
||||||
|
活动中
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === 'failed') {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
|
||||||
|
失败
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="border-slate-500/30 text-slate-400 bg-slate-500/10">
|
||||||
|
已关闭
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelect = (id: number) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAll = () => {
|
||||||
|
setSelected((prev) =>
|
||||||
|
prev.size === filtered.length ? new Set() : new Set(filtered.map((s) => s.id))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openReplay = (id: number) => {
|
||||||
|
setReplayId(id);
|
||||||
|
setReplayOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: '删除会话记录',
|
||||||
|
description: '确认删除该终端会话记录?删除后无法恢复。',
|
||||||
|
confirmText: '删除',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await terminalSessionApi.delete(id);
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await fetchSessions();
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchDelete = async () => {
|
||||||
|
if (selected.size === 0) return;
|
||||||
|
const ok = await confirm({
|
||||||
|
title: '批量删除',
|
||||||
|
description: `确认删除选中的 ${selected.size} 条终端会话记录?`,
|
||||||
|
confirmText: '删除',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await terminalSessionApi.batchDelete(Array.from(selected));
|
||||||
|
setSelected(new Set());
|
||||||
|
await fetchSessions();
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const allChecked = filtered.length > 0 && selected.size === filtered.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<div className="relative w-64">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜索用户/服务器/IP..."
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{['', 'active', 'closed', 'failed'].map((st) => (
|
||||||
|
<Button
|
||||||
|
key={st || 'all'}
|
||||||
|
variant={statusFilter === st ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className={
|
||||||
|
statusFilter === st
|
||||||
|
? 'h-8 bg-blue-600 hover:bg-blue-700 text-white'
|
||||||
|
: 'h-8 border-slate-700 text-slate-400 hover:text-slate-100 hover:bg-slate-800'
|
||||||
|
}
|
||||||
|
onClick={() => setStatusFilter(st)}
|
||||||
|
>
|
||||||
|
{st === '' ? '全部' : st === 'active' ? '活动中' : st === 'closed' ? '已关闭' : '失败'}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
|
||||||
|
onClick={fetchSessions}
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
onClick={handleBatchDelete}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
批量删除({selected.size})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sessions Table */}
|
||||||
|
<Card className="bg-slate-900 border-slate-800">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="border-t border-slate-800">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
|
||||||
|
<Search className="h-8 w-8 mb-3 opacity-40" />
|
||||||
|
<p className="text-sm">暂无终端会话记录</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||||
|
<TableHead className="text-slate-400 w-10">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allChecked}
|
||||||
|
onChange={toggleAll}
|
||||||
|
className="accent-blue-600 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-slate-400">用户</TableHead>
|
||||||
|
<TableHead className="text-slate-400">服务器</TableHead>
|
||||||
|
<TableHead className="text-slate-400">来源 IP</TableHead>
|
||||||
|
<TableHead className="text-slate-400">状态</TableHead>
|
||||||
|
<TableHead className="text-slate-400">时长</TableHead>
|
||||||
|
<TableHead className="text-slate-400">录制</TableHead>
|
||||||
|
<TableHead className="text-slate-400">开始时间</TableHead>
|
||||||
|
<TableHead className="text-slate-400 text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filtered.map((s) => (
|
||||||
|
<TableRow key={s.id} className="border-slate-800 hover:bg-slate-800/50">
|
||||||
|
<TableCell>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(s.id)}
|
||||||
|
onChange={() => toggleSelect(s.id)}
|
||||||
|
className="accent-blue-600 cursor-pointer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-sm text-slate-200">{s.username || '--'}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm text-slate-200">{s.server_name || '--'}</span>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{s.server_ip}:{s.server_port}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-sm text-slate-400">{s.client_ip || '--'}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{getStatusBadge(s.status)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-sm text-slate-300">{formatDuration(s.duration)}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-xs text-slate-500">{formatSize(s.record_size)}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-sm text-slate-400">
|
||||||
|
{s.start_time ? new Date(s.start_time).toLocaleString() : '--'}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-slate-400 hover:text-blue-400 hover:bg-slate-800"
|
||||||
|
onClick={() => openReplay(s.id)}
|
||||||
|
title="回放"
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-slate-400 hover:text-red-400 hover:bg-slate-800"
|
||||||
|
onClick={() => handleDelete(s.id)}
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ReplayDialog open={replayOpen} onOpenChange={setReplayOpen} sessionId={replayId} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -527,6 +527,25 @@ export const auditApi = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==================== 终端会话审计 API(WebSSH 录制) ==================== // Logan.Li
|
||||||
|
export const terminalSessionApi = {
|
||||||
|
list: async (params?: { page?: number; page_size?: number; server_id?: number; username?: string; status?: string }) => {
|
||||||
|
return http.get<ListResponse>('/terminal-sessions', params);
|
||||||
|
},
|
||||||
|
get: async (id: number) => {
|
||||||
|
return http.get(`/terminal-sessions/${id}`);
|
||||||
|
},
|
||||||
|
getRecord: async (id: number) => {
|
||||||
|
return http.get<string>(`/terminal-sessions/${id}/record`);
|
||||||
|
},
|
||||||
|
delete: async (id: number) => {
|
||||||
|
return http.delete(`/terminal-sessions/${id}`);
|
||||||
|
},
|
||||||
|
batchDelete: async (ids: number[]) => {
|
||||||
|
return http.post('/terminal-sessions/batch-delete', { ids });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// ==================== Ansible 自动化 API ====================
|
// ==================== Ansible 自动化 API ====================
|
||||||
export const ansibleApi = {
|
export const ansibleApi = {
|
||||||
// Playbook 管理
|
// Playbook 管理
|
||||||
|
|||||||
Reference in new issue
Block a user